Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Mailing;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
|
||||
|
||||
class NewsletterMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
protected $mailing;
|
||||
protected $recipient;
|
||||
protected $contentBlocks;
|
||||
protected $recipientLocale;
|
||||
protected $isTestMail;
|
||||
protected $fromBulkJob = false;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(Mailing $mailing, $recipientOrLocale, $contentBlocksOrIsTest = null, $locale = null, $fromBulkJob = false)
|
||||
{
|
||||
$this->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'))
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user