From f56ed9553c051135e3647c6901a594545dee5057 Mon Sep 17 00:00:00 2001 From: Tajim Date: Fri, 4 Sep 2026 11:38:10 +0600 Subject: [PATCH 1/4] feat(admin): streamline email composer with unified recipient list, subscriber imports and exclusion filter --- .../Controllers/Admin/EmailController.php | 107 +-- resources/js/pages/admin/EmailSend.vue | 612 ++++++++++-------- routes/admin.php | 1 + tests/Feature/AdminEmailTest.php | 187 ++---- 4 files changed, 458 insertions(+), 449 deletions(-) diff --git a/app/Http/Controllers/Admin/EmailController.php b/app/Http/Controllers/Admin/EmailController.php index 6058e3e2..651c0a38 100644 --- a/app/Http/Controllers/Admin/EmailController.php +++ b/app/Http/Controllers/Admin/EmailController.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Controller; use App\Mail\BulkAnnouncementMail; use App\Models\User; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Mail; @@ -41,13 +42,35 @@ public function create(): Response } /** - * Dispatch emails to either a single user or all/student subscribed users in bulk. + * Fetch raw subscribed email list for importing into the editor. + */ + public function recipients(Request $request): JsonResponse + { + $type = $request->query('type', 'all'); + + $query = User::where('receive_emails', true)->whereNotNull('email'); + + if ($type === 'students') { + $query->doesntHave('roles'); + } elseif ($type === 'staff') { + $query->has('roles'); + } + + $emails = $query->pluck('email')->unique()->values(); + + return response()->json([ + 'emails' => $emails, + 'count' => $emails->count(), + ]); + } + + /** + * Dispatch emails to a list of raw recipient emails. */ public function store(Request $request): RedirectResponse { $validated = $request->validate([ - 'recipient_type' => ['required', 'in:all,students,staff,single'], - 'recipient_email' => ['required_if:recipient_type,single', 'nullable', 'email', 'max:255'], + 'recipients' => ['required', 'string'], 'subject' => ['required', 'string', 'max:255'], 'body' => ['required', 'string'], 'image' => ['sometimes', 'nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'], @@ -56,6 +79,25 @@ public function store(Request $request): RedirectResponse $subject = $validated['subject']; $body = $validated['body']; + // Parse, clean, validate, and deduplicate emails + $rawLines = preg_split('/[\r\n,;]+/', $validated['recipients']); + $cleanedEmails = []; + + foreach ($rawLines as $line) { + $email = strtolower(trim($line)); + if (!empty($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) { + $cleanedEmails[$email] = true; + } + } + + $uniqueEmails = array_keys($cleanedEmails); + + if (empty($uniqueEmails)) { + return redirect() + ->route('admin.emails.create') + ->with('error', 'No valid recipient email addresses found in the list.'); + } + $imageUrl = null; if ($request->hasFile('image')) { $path = $request->file('image')->store('emails/images'); @@ -63,64 +105,29 @@ public function store(Request $request): RedirectResponse $imageUrl = str_starts_with($relativeUrl, 'http') ? $relativeUrl : url($relativeUrl); } - // Single user mode - if ($validated['recipient_type'] === 'single') { - $recipientEmail = $validated['recipient_email']; - $targetUser = User::where('email', $recipientEmail)->first(); + // Fetch known user names in bulk for personalization + $usersMap = User::whereIn('email', $uniqueEmails) + ->pluck('name', 'email') + ->toArray(); - Mail::to($recipientEmail)->queue( + foreach ($uniqueEmails as $email) { + $recipientName = $usersMap[$email] ?? null; + + Mail::to($email)->queue( new BulkAnnouncementMail( mailSubject: $subject, mailContent: $body, - recipientName: $targetUser?->name, + recipientName: $recipientName, imageUrl: $imageUrl, ) ); - - return redirect() - ->route('admin.emails.create') - ->with('success', "Email successfully queued for {$recipientEmail}."); } - // Bulk broadcast query - $usersQuery = User::where('receive_emails', true) - ->whereNotNull('email'); - - if ($validated['recipient_type'] === 'students') { - $usersQuery->doesntHave('roles'); - } elseif ($validated['recipient_type'] === 'staff') { - $usersQuery->has('roles'); - } - - $totalRecipients = $usersQuery->count(); - - if ($totalRecipients === 0) { - return redirect() - ->route('admin.emails.create') - ->with('error', 'No subscribed recipients found for the selected target.'); - } - - $usersQuery->chunkById(100, function ($users) use ($subject, $body, $imageUrl) { - foreach ($users as $user) { - Mail::to($user->email)->queue( - new BulkAnnouncementMail( - mailSubject: $subject, - mailContent: $body, - recipientName: $user->name, - imageUrl: $imageUrl, - ) - ); - } - }); - - $targetLabel = match ($validated['recipient_type']) { - 'students' => 'students (non-staff)', - 'staff' => 'staff members', - default => 'all subscribed', - }; + $totalCount = count($uniqueEmails); + $emailPlural = $totalCount === 1 ? 'recipient' : 'recipients'; return redirect() ->route('admin.emails.create') - ->with('success', "Email broadcast successfully queued for {$totalRecipients} {$targetLabel} recipients."); + ->with('success', "Email successfully queued for {$totalCount} unique {$emailPlural}."); } } diff --git a/resources/js/pages/admin/EmailSend.vue b/resources/js/pages/admin/EmailSend.vue index ec8e6ff8..3f050fad 100644 --- a/resources/js/pages/admin/EmailSend.vue +++ b/resources/js/pages/admin/EmailSend.vue @@ -5,15 +5,16 @@ import { Mail, Send, Users, - User, GraduationCap, ShieldCheck, AlertCircle, - AtSign, + CheckCircle2, Eye, X, Upload, Trash2, + Download, + MinusCircle, } from 'lucide-vue-next'; import { computed, ref } from 'vue'; @@ -39,13 +40,15 @@ const appName = computed(() => (page.props as any).appName || 'HSCStack'); const showConfirmModal = ref(false); const showPreviewModal = ref(false); +const showExcludeModal = ref(false); +const isImporting = ref(false); +const excludeInputText = ref(''); const imagePreview = ref(null); const fileInput = ref(null); const form = useForm({ - recipient_type: 'all' as 'all' | 'students' | 'staff' | 'single', - recipient_email: '', + recipients: '', subject: '', body: '', image: null as File | null, @@ -61,6 +64,51 @@ const formattedCurrentDate = computed(() => { }); }); +// Parse and analyze recipient emails in real-time +const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +const recipientStats = computed(() => { + const raw = form.recipients.trim(); + if (!raw) { + return { + validEmails: [] as string[], + invalidItems: [] as string[], + totalLines: 0, + duplicateCount: 0, + }; + } + + const tokens = raw + .split(/[\r\n,;]+/) + .map((t) => t.trim()) + .filter(Boolean); + const seen = new Set(); + const validEmails: string[] = []; + const invalidItems: string[] = []; + let duplicateCount = 0; + + for (const token of tokens) { + const lower = token.toLowerCase(); + if (emailRegex.test(lower)) { + if (seen.has(lower)) { + duplicateCount++; + } else { + seen.add(lower); + validEmails.push(lower); + } + } else { + invalidItems.push(token); + } + } + + return { + validEmails, + invalidItems, + totalLines: tokens.length, + duplicateCount, + }; +}); + const handleImageSelect = (event: Event) => { const target = event.target as HTMLInputElement; @@ -80,19 +128,82 @@ const handleRemoveImage = () => { } }; +// Import helper +const importSubscribers = async (type: 'all' | 'students' | 'staff') => { + if (isImporting.value) return; + isImporting.value = true; + + try { + const response = await fetch(`/admin/emails/recipients?type=${type}`, { + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + + if (response.ok) { + const data = await response.json(); + const importedList: string[] = data.emails || []; + + // Merge with existing emails avoiding duplicates + const currentTokens = form.recipients + .split(/[\r\n,;]+/) + .map((t) => t.trim().toLowerCase()) + .filter(Boolean); + + const mergedSet = new Set([...currentTokens, ...importedList]); + form.recipients = Array.from(mergedSet).join('\n'); + } + } catch (e) { + console.error('Failed to import recipients:', e); + } finally { + isImporting.value = false; + } +}; + +// Exclude helper +const applyExclusions = () => { + const excludeTokens = excludeInputText.value + .split(/[\r\n,;]+/) + .map((t) => t.trim().toLowerCase()) + .filter(Boolean); + + if (excludeTokens.length === 0) { + showExcludeModal.value = false; + return; + } + + const excludeSet = new Set(excludeTokens); + const currentTokens = form.recipients + .split(/[\r\n,;]+/) + .map((t) => t.trim()) + .filter(Boolean); + + const filtered = currentTokens.filter( + (token) => !excludeSet.has(token.toLowerCase()), + ); + + form.recipients = filtered.join('\n'); + excludeInputText.value = ''; + showExcludeModal.value = false; +}; + +const cleanAndFormatRecipients = () => { + const valid = recipientStats.value.validEmails; + form.recipients = valid.join('\n'); +}; + const handleSendClick = () => { - if (form.recipient_type === 'single' && !form.recipient_email.trim()) { + if (recipientStats.value.validEmails.length === 0) { form.setError( - 'recipient_email', - 'Please provide a valid recipient email.', + 'recipients', + 'Please provide at least one valid recipient email.', ); - return; } if (!form.subject.trim() || !form.body.trim()) { form.validate(); - return; } @@ -130,8 +241,8 @@ const submitForm = () => {

- Compose and dispatch email announcements to subscribed users - or individual accounts. + Compose announcements or direct messages with a unified + recipient list, subscriber imports, and exclusions.

@@ -157,12 +268,12 @@ const submitForm = () => { - Subscribers: + Total Subscribers: - {{ recipientCount }} users + {{ recipientCount }} @@ -170,213 +281,158 @@ const submitForm = () => {
- -
-
- -
- -
-
- -
- -
-

- {{ form.errors.recipient_email }} -

-
- - -
- -

- There are currently no active users with email notifications - enabled in the selected recipient target. -

-
-
@@ -834,28 +931,14 @@ const submitForm = () => {

- - Are you sure you want to send this email to - {{ form.recipient_email }}? - - - Are you sure you want to send this broadcast? The emails - will be dispatched to - {{ props.studentsCount }} students - (public non-staff users) who have enabled email updates. - - - Are you sure you want to send this broadcast? The emails - will be dispatched to - {{ props.staffCount }} staff members - who have enabled email updates. - - - Are you sure you want to send this broadcast? The emails - will be dispatched to all - {{ props.recipientCount }} users who - have enabled email updates. + Are you sure you want to send this broadcast? The emails + will be queued asynchronously. + + ({{ recipientStats.duplicateCount }} duplicates were + detected and will only receive 1 email).

@@ -926,3 +1009,4 @@ const submitForm = () => { text-decoration: underline; } + diff --git a/routes/admin.php b/routes/admin.php index 4a7539a0..a3cd2fdc 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -105,6 +105,7 @@ // Emails Route::middleware('permission:send email')->group(function () { Route::get('/emails/send', [AdminEmailController::class, 'create'])->name('emails.create'); + Route::get('/emails/recipients', [AdminEmailController::class, 'recipients'])->name('emails.recipients'); Route::post('/emails/send', [AdminEmailController::class, 'store'])->name('emails.store'); }); diff --git a/tests/Feature/AdminEmailTest.php b/tests/Feature/AdminEmailTest.php index 3ee1b056..83c8838a 100644 --- a/tests/Feature/AdminEmailTest.php +++ b/tests/Feature/AdminEmailTest.php @@ -32,84 +32,90 @@ $response->assertSessionHas('error', 'You do not have permission to perform this action.'); }); -test('admin can send email directly to a single user', function () { - Mail::fake(); - - $admin = User::factory()->create(['email' => 'admin@example.com']); +test('admin can fetch subscriber emails for import', function () { + $admin = User::factory()->create(); $admin->assignRole('admin'); - $targetUser = User::factory()->create([ - 'name' => 'John Doe', - 'email' => 'john@example.com', - ]); - - $response = $this->actingAs($admin)->post(route('admin.emails.store'), [ - 'recipient_type' => 'single', - 'recipient_email' => 'john@example.com', - 'subject' => 'Account Verification Notice', - 'body' => '

Hello John, please review your account.

', - ]); + $student = User::factory()->create(['email' => 'student@example.com', 'receive_emails' => true]); + $staff = User::factory()->create(['email' => 'staff@example.com', 'receive_emails' => true]); + $editorRole = Role::findOrCreate('editor', 'web'); + $staff->assignRole($editorRole); - $response->assertRedirect(route('admin.emails.create')); - $response->assertSessionHas('success'); + $unsubscribed = User::factory()->create(['email' => 'unsub@example.com', 'receive_emails' => false]); - Mail::assertQueued(BulkAnnouncementMail::class, function ($mail) { - return $mail->hasTo('john@example.com') && - $mail->mailSubject === 'Account Verification Notice' && - $mail->mailContent === '

Hello John, please review your account.

' && - $mail->recipientName === 'John Doe'; - }); + // All subscribed + $responseAll = $this->actingAs($admin)->get(route('admin.emails.recipients', ['type' => 'all'])); + $responseAll->assertOk(); + $dataAll = $responseAll->json(); + expect($dataAll['emails'])->toContain('student@example.com', 'staff@example.com') + ->and($dataAll['emails'])->not->toContain('unsub@example.com'); + + // Students only + $responseStudents = $this->actingAs($admin)->get(route('admin.emails.recipients', ['type' => 'students'])); + $responseStudents->assertOk(); + $dataStudents = $responseStudents->json(); + expect($dataStudents['emails'])->toContain('student@example.com') + ->and($dataStudents['emails'])->not->toContain('staff@example.com'); + + // Staff only + $responseStaff = $this->actingAs($admin)->get(route('admin.emails.recipients', ['type' => 'staff'])); + $responseStaff->assertOk(); + $dataStaff = $responseStaff->json(); + expect($dataStaff['emails'])->toContain('staff@example.com') + ->and($dataStaff['emails'])->not->toContain('student@example.com'); }); -test('admin can queue bulk emails only to users with receive_emails enabled', function () { +test('admin can send email to custom and platform emails with automatic deduplication', function () { Mail::fake(); $admin = User::factory()->create(['email' => 'admin@example.com']); $admin->assignRole('admin'); - // Create subscribed users - $subscribed1 = User::factory()->create(['email' => 'sub1@example.com', 'receive_emails' => true]); - $subscribed2 = User::factory()->create(['email' => 'sub2@example.com', 'receive_emails' => true]); + $john = User::factory()->create([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); - // Create unsubscribed user - $unsubscribed = User::factory()->create(['email' => 'unsub@example.com', 'receive_emails' => false]); + $recipientsRaw = "john@example.com\nJOHN@EXAMPLE.COM\nextra-lead@3rdparty.com\ninvalid-email-format\n extra-lead@3rdparty.com "; $response = $this->actingAs($admin)->post(route('admin.emails.store'), [ - 'recipient_type' => 'all', - 'subject' => 'Platform Update Announcement', - 'body' => '

Check out our brand new video resources!

', + 'recipients' => $recipientsRaw, + 'subject' => 'Platform Update Notice', + 'body' => '

Hello, check out the new features!

', ]); $response->assertRedirect(route('admin.emails.create')); $response->assertSessionHas('success'); - Mail::assertQueued(BulkAnnouncementMail::class, function ($mail) use ($subscribed1) { - return $mail->hasTo($subscribed1->email) && - $mail->mailSubject === 'Platform Update Announcement' && - $mail->mailContent === '

Check out our brand new video resources!

'; + // John receives email with personalized name (only once) + Mail::assertQueued(BulkAnnouncementMail::class, function ($mail) { + return $mail->hasTo('john@example.com') && + $mail->mailSubject === 'Platform Update Notice' && + $mail->recipientName === 'John Doe'; }); - Mail::assertQueued(BulkAnnouncementMail::class, function ($mail) use ($subscribed2) { - return $mail->hasTo($subscribed2->email); + // 3rd party lead receives email with null name (only once) + Mail::assertQueued(BulkAnnouncementMail::class, function ($mail) { + return $mail->hasTo('extra-lead@3rdparty.com') && + $mail->mailSubject === 'Platform Update Notice' && + $mail->recipientName === null; }); - Mail::assertNotQueued(BulkAnnouncementMail::class, function ($mail) use ($unsubscribed) { - return $mail->hasTo($unsubscribed->email); - }); + // Total queued should be exactly 2 + Mail::assertQueuedCount(2); }); -test('single email requires valid recipient email', function () { +test('submitting without valid recipients fails validation', function () { $admin = User::factory()->create(); $admin->assignRole('admin'); $response = $this->actingAs($admin)->post(route('admin.emails.store'), [ - 'recipient_type' => 'single', - 'recipient_email' => '', + 'recipients' => '', 'subject' => 'Test Subject', 'body' => '

Test Body

', ]); - $response->assertSessionHasErrors(['recipient_email']); + $response->assertSessionHasErrors(['recipients']); }); test('admin can upload cover image and attach url to queued mail', function () { @@ -119,15 +125,10 @@ $admin = User::factory()->create(); $admin->assignRole('admin'); - $targetUser = User::factory()->create([ - 'email' => 'student@example.com', - ]); - $image = UploadedFile::fake()->image('banner.jpg', 600, 300); $response = $this->actingAs($admin)->post(route('admin.emails.store'), [ - 'recipient_type' => 'single', - 'recipient_email' => 'student@example.com', + 'recipients' => 'student@example.com', 'subject' => 'Image Test Subject', 'body' => '

Image Test Body

', 'image' => $image, @@ -141,87 +142,3 @@ ! empty($mail->imageUrl); }); }); - -test('admin can queue broadcast emails specifically to students non-staff users', function () { - Mail::fake(); - - $admin = User::factory()->create(['email' => 'admin@example.com']); - $admin->assignRole('admin'); - - $editor = User::factory()->create(['email' => 'editor@example.com', 'receive_emails' => true]); - $editorRole = Role::findOrCreate('editor', 'web'); - $editor->assignRole($editorRole); - - $student1 = User::factory()->create(['email' => 'student1@example.com', 'receive_emails' => true]); - $student2 = User::factory()->create(['email' => 'student2@example.com', 'receive_emails' => true]); - $unsubStudent = User::factory()->create(['email' => 'unsub_student@example.com', 'receive_emails' => false]); - - $response = $this->actingAs($admin)->post(route('admin.emails.store'), [ - 'recipient_type' => 'students', - 'subject' => 'Student Community Update', - 'body' => '

Special notice for all students.

', - ]); - - $response->assertRedirect(route('admin.emails.create')); - $response->assertSessionHas('success'); - - // Students with receive_emails=true should receive the email - Mail::assertQueued(BulkAnnouncementMail::class, function ($mail) use ($student1) { - return $mail->hasTo($student1->email) && - $mail->mailSubject === 'Student Community Update'; - }); - - Mail::assertQueued(BulkAnnouncementMail::class, function ($mail) use ($student2) { - return $mail->hasTo($student2->email); - }); - - // Staff/role-assigned users should NOT receive it - Mail::assertNotQueued(BulkAnnouncementMail::class, function ($mail) use ($editor) { - return $mail->hasTo($editor->email); - }); - - // Unsubscribed students should NOT receive it - Mail::assertNotQueued(BulkAnnouncementMail::class, function ($mail) use ($unsubStudent) { - return $mail->hasTo($unsubStudent->email); - }); -}); - -test('admin can queue broadcast emails specifically to staff members', function () { - Mail::fake(); - - $admin = User::factory()->create(['email' => 'admin@example.com']); - $admin->assignRole('admin'); - - $editor = User::factory()->create(['email' => 'editor@example.com', 'receive_emails' => true]); - $editorRole = Role::findOrCreate('editor', 'web'); - $editor->assignRole($editorRole); - - $student = User::factory()->create(['email' => 'student@example.com', 'receive_emails' => true]); - $unsubStaff = User::factory()->create(['email' => 'unsub_staff@example.com', 'receive_emails' => false]); - $unsubStaff->assignRole($editorRole); - - $response = $this->actingAs($admin)->post(route('admin.emails.store'), [ - 'recipient_type' => 'staff', - 'subject' => 'Internal Staff Announcement', - 'body' => '

Meeting at 5 PM.

', - ]); - - $response->assertRedirect(route('admin.emails.create')); - $response->assertSessionHas('success'); - - // Subscribed staff members should receive it - Mail::assertQueued(BulkAnnouncementMail::class, function ($mail) use ($editor) { - return $mail->hasTo($editor->email) && - $mail->mailSubject === 'Internal Staff Announcement'; - }); - - // Students should NOT receive it - Mail::assertNotQueued(BulkAnnouncementMail::class, function ($mail) use ($student) { - return $mail->hasTo($student->email); - }); - - // Unsubscribed staff should NOT receive it - Mail::assertNotQueued(BulkAnnouncementMail::class, function ($mail) use ($unsubStaff) { - return $mail->hasTo($unsubStaff->email); - }); -}); From 5fdc289b40595ccd94c17e9e04ddb7ed7401fd74 Mon Sep 17 00:00:00 2001 From: Tajim Date: Fri, 4 Sep 2026 11:40:52 +0600 Subject: [PATCH 2/4] feat(admin): consolidate recipient import dropdown and remove exclusion modal --- resources/js/pages/admin/EmailSend.vue | 255 +++++++++++-------------- 1 file changed, 116 insertions(+), 139 deletions(-) diff --git a/resources/js/pages/admin/EmailSend.vue b/resources/js/pages/admin/EmailSend.vue index 3f050fad..28c87838 100644 --- a/resources/js/pages/admin/EmailSend.vue +++ b/resources/js/pages/admin/EmailSend.vue @@ -14,10 +14,10 @@ import { Upload, Trash2, Download, - MinusCircle, + ChevronDown, } from 'lucide-vue-next'; -import { computed, ref } from 'vue'; +import { computed, onBeforeUnmount, onMounted, ref } from 'vue'; import HTMLEditor from '@/components/HTMLEditor.vue'; const props = defineProps({ @@ -40,10 +40,10 @@ const appName = computed(() => (page.props as any).appName || 'HSCStack'); const showConfirmModal = ref(false); const showPreviewModal = ref(false); -const showExcludeModal = ref(false); +const isImportDropdownOpen = ref(false); const isImporting = ref(false); -const excludeInputText = ref(''); +const importDropdownRef = ref(null); const imagePreview = ref(null); const fileInput = ref(null); @@ -64,6 +64,24 @@ const formattedCurrentDate = computed(() => { }); }); +// Click outside handler for import dropdown +const handleClickOutside = (event: MouseEvent) => { + if ( + importDropdownRef.value && + !importDropdownRef.value.contains(event.target as Node) + ) { + isImportDropdownOpen.value = false; + } +}; + +onMounted(() => { + document.addEventListener('click', handleClickOutside); +}); + +onBeforeUnmount(() => { + document.removeEventListener('click', handleClickOutside); +}); + // Parse and analyze recipient emails in real-time const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -132,6 +150,7 @@ const handleRemoveImage = () => { const importSubscribers = async (type: 'all' | 'students' | 'staff') => { if (isImporting.value) return; isImporting.value = true; + isImportDropdownOpen.value = false; try { const response = await fetch(`/admin/emails/recipients?type=${type}`, { @@ -161,33 +180,6 @@ const importSubscribers = async (type: 'all' | 'students' | 'staff') => { } }; -// Exclude helper -const applyExclusions = () => { - const excludeTokens = excludeInputText.value - .split(/[\r\n,;]+/) - .map((t) => t.trim().toLowerCase()) - .filter(Boolean); - - if (excludeTokens.length === 0) { - showExcludeModal.value = false; - return; - } - - const excludeSet = new Set(excludeTokens); - const currentTokens = form.recipients - .split(/[\r\n,;]+/) - .map((t) => t.trim()) - .filter(Boolean); - - const filtered = currentTokens.filter( - (token) => !excludeSet.has(token.toLowerCase()), - ); - - form.recipients = filtered.join('\n'); - excludeInputText.value = ''; - showExcludeModal.value = false; -}; - const cleanAndFormatRecipients = () => { const valid = recipientStats.value.validEmails; form.recipients = valid.join('\n'); @@ -241,8 +233,8 @@ const submitForm = () => {

- Compose announcements or direct messages with a unified - recipient list, subscriber imports, and exclusions. + Compose and dispatch email announcements to custom recipient + lists or imported platform subscribers.

@@ -281,7 +273,7 @@ const submitForm = () => {
- +
@@ -299,63 +291,107 @@ const submitForm = () => { class="mt-0.5 text-xs text-slate-400 dark:text-gray-500" > Paste any third-party list, single email, or import - registered platform subscribers below. + registered platform subscribers.

- -
- - - - - + +
+ +
+ - + +
+ + + + + +
+
+
@@ -626,64 +662,6 @@ const submitForm = () => {
- - -
-
-
-
- - Exclude / Remove Emails -
- -
- -

- Paste the emails you wish to exclude (comma or newline - separated). They will be removed from your current - recipients list. -

- - - -
- - -
-
-
-
-
{ text-decoration: underline; } - From 2d42585e2f92c6816d958a66c097c6fa39e587e9 Mon Sep 17 00:00:00 2001 From: Tajim Date: Fri, 4 Sep 2026 11:42:57 +0600 Subject: [PATCH 3/4] refactor(email): simplify email footer and point to support center --- resources/js/pages/admin/EmailSend.vue | 13 +++---------- resources/views/emails/bulk_announcement.blade.php | 7 ++----- resources/views/emails/default.blade.php | 7 ++----- 3 files changed, 7 insertions(+), 20 deletions(-) diff --git a/resources/js/pages/admin/EmailSend.vue b/resources/js/pages/admin/EmailSend.vue index 28c87838..4e1575f1 100644 --- a/resources/js/pages/admin/EmailSend.vue +++ b/resources/js/pages/admin/EmailSend.vue @@ -838,19 +838,12 @@ const submitForm = () => {

- You are receiving this email because you have an - active account on HSCStack or subscribed to our - updates. -

-

- Manage your email preferences anytime in your + Need help or want to manage your preferences? Visit our Account SettingsSupport Center.

diff --git a/resources/views/emails/bulk_announcement.blade.php b/resources/views/emails/bulk_announcement.blade.php index bf242c07..61e2477a 100644 --- a/resources/views/emails/bulk_announcement.blade.php +++ b/resources/views/emails/bulk_announcement.blade.php @@ -210,12 +210,9 @@ Support Us - diff --git a/resources/views/emails/default.blade.php b/resources/views/emails/default.blade.php index 233e3dc1..953492d4 100644 --- a/resources/views/emails/default.blade.php +++ b/resources/views/emails/default.blade.php @@ -180,12 +180,9 @@ Support Us - From b285e21225ca9ab84720e6302b242203d70361bc Mon Sep 17 00:00:00 2001 From: Tajim Date: Fri, 4 Sep 2026 11:44:09 +0600 Subject: [PATCH 4/4] refactor(email): update footer with both account settings and support center options --- resources/js/pages/admin/EmailSend.vue | 9 +++++++-- resources/views/emails/bulk_announcement.blade.php | 3 +-- resources/views/emails/default.blade.php | 3 +-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/resources/js/pages/admin/EmailSend.vue b/resources/js/pages/admin/EmailSend.vue index 4e1575f1..abd66ee3 100644 --- a/resources/js/pages/admin/EmailSend.vue +++ b/resources/js/pages/admin/EmailSend.vue @@ -840,11 +840,16 @@ const submitForm = () => {

- Need help or want to manage your preferences? Visit our + Manage email preferences in your + Account Settings, or visit our Support Center. + > + if you need assistance or don't have an account.

diff --git a/resources/views/emails/bulk_announcement.blade.php b/resources/views/emails/bulk_announcement.blade.php index 61e2477a..df0df942 100644 --- a/resources/views/emails/bulk_announcement.blade.php +++ b/resources/views/emails/bulk_announcement.blade.php @@ -211,8 +211,7 @@ diff --git a/resources/views/emails/default.blade.php b/resources/views/emails/default.blade.php index 953492d4..6e39cab2 100644 --- a/resources/views/emails/default.blade.php +++ b/resources/views/emails/default.blade.php @@ -181,8 +181,7 @@