mailing = $mailing; $this->fromBulkJob = $fromBulkJob; // Handle test mail mode (when second parameter is a locale string and third is boolean) if (is_string($recipientOrLocale) && is_bool($contentBlocksOrIsTest)) { $this->recipientLocale = $recipientOrLocale; $this->isTestMail = $contentBlocksOrIsTest; $this->recipient = null; // Locale-scoped relations (e.g. CityLocale::translations) are queried during // content generation below, so the app locale must be set before that happens // rather than later in build() - otherwise a leftover locale from a previously // processed mailing job on the same worker can leak into this one. App::setLocale($this->recipientLocale); $this->contentBlocks = $this->generateContentBlocksForLocale($this->recipientLocale); } else { // Normal mode (backward compatibility) $this->recipient = $recipientOrLocale; $this->recipientLocale = $locale ?: ($recipientOrLocale->lang_preference ?? timebank_config('base_language', 'en')); $this->isTestMail = false; App::setLocale($this->recipientLocale); $this->contentBlocks = $contentBlocksOrIsTest ?: $this->generateContentBlocks(); } } /** * Build the message. */ public function build() { // Set locale for the email App::setLocale($this->recipientLocale); $fromAddress = timebank_config("mailing.from_address.{$this->mailing->type}"); $subject = $this->mailing->getSubjectForLocale($this->recipientLocale); $emailSubject = $subject; if ($this->isTestMail) { $emailSubject = "[TEST - {$this->recipientLocale}] " . $subject; } $mail = $this ->from($fromAddress, config('app.name')) ->subject($emailSubject) ->view('emails.newsletter.wrapper') ->with([ 'subject' => $subject, 'mailingTitle' => $this->mailing->title, 'mailingId' => $this->mailing->id, 'locale' => $this->recipientLocale, 'contentBlocks' => $this->contentBlocks, 'unsubscribeUrl' => $this->generateUnsubscribeUrl(), 'isTestMail' => $this->isTestMail, ]); // Add bounce tracking headers $bounceEmail = config('mail.bounce_processing.bounce_address', config('mail.from.address')); if ($bounceEmail && !$this->isTestMail) { $mail->withSymfonyMessage(function ($message) use ($bounceEmail) { $headers = $message->getHeaders(); // Set Return-Path for bounce handling $headers->addPathHeader('Return-Path', $bounceEmail); // Add custom tracking headers $headers->addTextHeader('X-Mailing-ID', $this->mailing->id); $headers->addTextHeader('X-Recipient-Email', $this->recipient->email ?? ''); // Add envelope sender $message->returnPath($bounceEmail); }); } return $mail; } /** * Generate content blocks with proper translations */ protected function generateContentBlocks() { $blocks = []; foreach ($this->mailing->getSelectedPostsWithTranslations() as $post) { // Get translation for recipient's language with fallback $translation = $this->mailing->getPostTranslationForLocale($post->id, $this->recipientLocale); if (!$translation) { continue; // Skip posts without translations } // Determine post type for template selection $postType = $this->determinePostType($post); // Prepare post data for template $postData = $this->preparePostData($post, $translation); $blocks[] = [ 'type' => $postType, 'data' => $postData, 'template' => \App\Models\Mailing::blockTemplate($postType) ]; } return $blocks; } /** * Generate content blocks for a specific locale (test mode) */ protected function generateContentBlocksForLocale($locale) { $blocks = []; $contentBlocks = $this->mailing->getContentBlocksForLocale($locale); foreach ($contentBlocks as $block) { $post = \App\Models\Post::with(['translations', 'category', 'meeting.location.city.translations', 'poll'])->find($block['post_id']); if (!$post) { continue; } // Get translation for the specific locale $translation = $this->mailing->getPostTranslationForLocale($post->id, $locale); if (!$translation) { continue; // Skip posts without translations in this locale } // Determine post type for template selection $postType = $this->determinePostType($post); // Prepare post data for template $postData = $this->preparePostData($post, $translation); $blocks[] = [ 'type' => $postType, 'data' => $postData, 'template' => \App\Models\Mailing::blockTemplate($postType) ]; } return $blocks; } /** * Determine post type based on category or content */ protected function determinePostType($post) { return \App\Models\Mailing::determinePostType($post); } /** * Prepare post data for email template */ protected function preparePostData($post, $translation) { // Generate fully localized URL with translated route path for recipient's language $url = LaravelLocalization::getURLFromRouteNameTranslated( $this->recipientLocale, '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($this->recipientLocale)->translatedFormat('M j, Y'), 'author' => $post->author ? $post->author->name : null, 'show_button' => $this->mailing->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, $this->recipientLocale)); // Add category information if ($post->category) { $categoryTranslation = $post->category->translations()->where('locale', $this->recipientLocale)->first(); $data['category'] = $categoryTranslation ? $categoryTranslation->name : $post->category->translations()->first()->name; } // Add location prefix for news (based on existing news-card-full logic) if ($post->category && $post->category->categoryable && $post->category->categoryable->translations) { $locationTranslation = $post->category->categoryable->translations->where('locale', $this->recipientLocale)->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', $this->recipientLocale)?->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($this->recipientLocale)->translatedFormat('j F')); $data['event_time'] = $eventDate->locale($this->recipientLocale)->translatedFormat('H:i'); if ($post->meeting->till) { $data['event_time_end'] = \Carbon\Carbon::parse($post->meeting->till)->locale($this->recipientLocale)->translatedFormat('H:i'); } } // Add image if available - 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-' . $this->recipientLocale; $data['media_caption'] = $media->getCustomProperty($captionKey, ''); $data['media_owner'] = $media->getCustomProperty('owner', ''); } } return $data; } /** * Generate unsubscribe URL for the recipient */ protected function generateUnsubscribeUrl() { // For test mails, return a placeholder URL if ($this->isTestMail || !$this->recipient) { return '#test-unsubscribe-link'; } // Create signed URL for unsubscribing from this mailing type return route('newsletter.unsubscribe', [ 'email' => $this->recipient->email, 'type' => $this->mailing->type, 'signature' => hash_hmac('sha256', $this->recipient->email . $this->mailing->type, config('app.key')) ]); } }