diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index a548779..737b3a5 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -22,7 +22,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 with: - ref: ${{ github.head_ref }} + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - name: Run Laravel Pint uses: aglipanci/laravel-pint-action@latest diff --git a/.github/workflows/test-runner.yml b/.github/workflows/test-runner.yml index b430b07..b78f75f 100644 --- a/.github/workflows/test-runner.yml +++ b/.github/workflows/test-runner.yml @@ -41,7 +41,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 with: - ref: ${{ github.head_ref }} + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - name: Validate composer.json and composer.lock run: composer validate --strict diff --git a/database/factories/MailLogFactory.php b/database/factories/MailLogFactory.php new file mode 100644 index 0000000..b379e04 --- /dev/null +++ b/database/factories/MailLogFactory.php @@ -0,0 +1,87 @@ + + */ +class MailLogFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string<\Illuminate\Database\Eloquent\Model> + */ + protected $model = MailLog::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'site_id' => Site::inRandomOrder()->first()?->id ?? Site::factory()->create()->id, + 'message_id' => fake()->uuid(), + 'from' => fake()->email(), + 'to' => fake()->email(), + 'cc' => null, + 'bcc' => null, + 'subject' => fake()->sentence(), + 'body' => '

'.fake()->paragraph().'

', + 'headers' => [ + 'Content-Type' => 'text/html; charset=UTF-8', + 'X-Mailer' => 'Laravel', + ], + 'attachments' => [], + 'sender_id' => User::inRandomOrder()->first()?->id ?? User::factory()->create()->id, + 'recipient_id' => null, + 'status' => fake()->randomElement(['sent', 'sending', 'failed']), + 'sent_at' => fake()->dateTimeBetween('-1 month', 'now'), + 'data' => [], + 'opened' => null, + 'delivered' => null, + 'complaint' => null, + 'bounced' => null, + ]; + } + + /** + * Indicate that the mail log is sent. + */ + public function sent(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'sent', + 'sent_at' => now(), + ]); + } + + /** + * Indicate that the mail log is sending. + */ + public function sending(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'sending', + 'sent_at' => null, + ]); + } + + /** + * Indicate that the mail log failed. + */ + public function failed(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'failed', + 'sent_at' => null, + ]); + } +} diff --git a/database/migrations/2025_07_25_183516_create_mail_logs_table.php b/database/migrations/2025_07_25_183516_create_mail_logs_table.php new file mode 100644 index 0000000..d92e96e --- /dev/null +++ b/database/migrations/2025_07_25_183516_create_mail_logs_table.php @@ -0,0 +1,53 @@ +increments('id'); + + $table->foreignId('site_id')->nullable()->constrained('sites')->onDelete('cascade'); + + $table->string('from')->nullable(); + $table->string('to')->nullable(); + $table->string('cc')->nullable(); + $table->string('bcc')->nullable(); + $table->string('subject'); + $table->text('body'); + $table->text('headers')->nullable(); + $table->longText('attachments')->nullable(); + $table->uuid('message_id')->nullable(); + $table->string('status')->nullable(); + $table->longText('data')->nullable(); + $table->timestamp('opened')->nullable(); + $table->timestamp('delivered')->nullable(); + $table->timestamp('complaint')->nullable(); + $table->timestamp('bounced')->nullable(); + + $table->foreignId('sender_id')->nullable()->constrained('users')->onDelete('set null'); + $table->foreignId('recipient_id')->nullable()->constrained('users')->onDelete('set null'); + + $table->timestamps(); + + $table->index('message_id'); + $table->index('status'); + $table->index(['site_id', 'created_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('mail_logs'); + } +}; diff --git a/database/migrations/2025_07_25_190010_add_sent_at_to_mail_logs_table.php b/database/migrations/2025_07_25_190010_add_sent_at_to_mail_logs_table.php new file mode 100644 index 0000000..4d2d2c4 --- /dev/null +++ b/database/migrations/2025_07_25_190010_add_sent_at_to_mail_logs_table.php @@ -0,0 +1,28 @@ +timestamp('sent_at')->nullable()->after('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('mail_logs', function (Blueprint $table) { + $table->dropColumn('sent_at'); + }); + } +}; diff --git a/resources/lang/en/email.php b/resources/lang/en/email.php new file mode 100644 index 0000000..c084445 --- /dev/null +++ b/resources/lang/en/email.php @@ -0,0 +1,59 @@ + 'Send Email', + 'send_email_to' => 'Send Email to :name', + 'sender' => 'Sender', + 'recipient' => 'Recipient', + 'cc' => 'CC', + 'cc_help' => 'Additional recipients', + 'bcc' => 'BCC', + 'bcc_help' => 'Hidden recipients', + 'subject' => 'Subject', + 'subject_placeholder' => 'Enter email subject', + 'message' => 'Message', + 'message_placeholder' => 'Enter message content...', + 'send' => 'Send', + 'cancel' => 'Cancel', + 'email_queued' => 'Email queued', + 'email_queued_to' => 'Email to :email has been queued for sending.', + 'email_sent' => 'Email sent', + 'email_sent_to' => 'Email to :email has been successfully sent.', + 'permission_denied' => 'Permission denied', + 'permission_denied_message' => 'You do not have permission to send emails.', + 'error' => 'Error', + 'send_error_message' => 'An error occurred while sending the email: :error', + 'email_form_title' => 'Send Email', + 'email_form_description' => 'Send an email to the selected user', + 'your_email' => 'Your email address', + 'recipient_email' => 'Recipient email address', + 'invalid_cc_email' => 'One or more CC email addresses are invalid.', + 'invalid_bcc_email' => 'One or more BCC email addresses are invalid.', + + // Mail Log / Outbox + 'outbox' => 'Email Outbox', + 'outbox_description' => 'View all outgoing emails', + 'email_details' => 'Email Details', + 'email_body' => 'Email Body', + 'metadata' => 'Metadata', + 'user_information' => 'User Information', + 'headers' => 'Headers', + 'from' => 'From', + 'to' => 'To', + 'sent_at' => 'Sent At', + 'message_id' => 'Message ID', + 'status' => 'Status', + 'sent' => 'Sent', + 'sending' => 'Sending', + 'failed' => 'Failed', + 'sent_today' => 'Sent Today', + 'sent_this_week' => 'Sent This Week', + 'back_to_outbox' => 'Back to Outbox', + 'view_email' => 'View Email', + 'no_emails' => 'No emails found', + 'email_log' => 'Email Log', + 'show_html' => 'Show HTML', + 'show_preview' => 'Show Preview', + 'email_preview' => 'Email Preview', + 'html_source' => 'HTML Source', +]; diff --git a/resources/lang/hr/email.php b/resources/lang/hr/email.php new file mode 100644 index 0000000..08bdf67 --- /dev/null +++ b/resources/lang/hr/email.php @@ -0,0 +1,59 @@ + 'Pošaljite e-mail', + 'send_email_to' => 'Pošaljite e-mail korisniku :name', + 'sender' => 'Pošaljitelj', + 'recipient' => 'Primatelj', + 'cc' => 'CC', + 'cc_help' => 'Dodatni primatelji', + 'bcc' => 'BCC', + 'bcc_help' => 'Skriveni primatelji', + 'subject' => 'Predmet', + 'subject_placeholder' => 'Unesite predmet e-maila', + 'message' => 'Poruka', + 'message_placeholder' => 'Unesite sadržaj poruke...', + 'send' => 'Pošaljite', + 'cancel' => 'Otkaži', + 'email_queued' => 'E-mail je u redu za slanje', + 'email_queued_to' => 'E-mail za :email je dodan u red za slanje.', + 'email_sent' => 'E-mail poslan', + 'email_sent_to' => 'E-mail poslan na :email je uspješno poslan.', + 'permission_denied' => 'Dozvola odbijena', + 'permission_denied_message' => 'Nemate dozvolu za slanje e-mailova.', + 'error' => 'Greška', + 'send_error_message' => 'Došlo je do greške prilikom slanja e-maila: :error', + 'email_form_title' => 'Pošaljite e-mail', + 'email_form_description' => 'Pošaljite e-mail odabranom korisniku', + 'your_email' => 'Vaša e-mail adresa', + 'recipient_email' => 'E-mail adresa primatelja', + 'invalid_cc_email' => 'Jedna ili više CC e-mail adresa nije valjana.', + 'invalid_bcc_email' => 'Jedna ili više BCC e-mail adresa nije valjana.', + + // Mail Log / Outbox + 'outbox' => 'Odlazni e-mailovi', + 'outbox_description' => 'Pregled svih odlaznih e-mailova', + 'email_details' => 'Detalji e-maila', + 'email_body' => 'Sadržaj e-maila', + 'metadata' => 'Metapodaci', + 'user_information' => 'Informacije o korisnicima', + 'headers' => 'Zaglavlja', + 'from' => 'Od', + 'to' => 'Za', + 'sent_at' => 'Poslano u', + 'message_id' => 'ID poruke', + 'status' => 'Status', + 'sent' => 'Poslano', + 'sending' => 'Šalje se', + 'failed' => 'Neuspješno', + 'sent_today' => 'Poslano danas', + 'sent_this_week' => 'Poslano ovaj tjedan', + 'back_to_outbox' => 'Natrag na odlazne poruke', + 'view_email' => 'Prikaži e-mail', + 'no_emails' => 'Nema pronađenih e-mailova', + 'email_log' => 'Dnevnik e-mailova', + 'show_html' => 'Prikaži HTML', + 'show_preview' => 'Prikaži pregled', + 'email_preview' => 'Pregled e-maila', + 'html_source' => 'HTML kod', +]; diff --git a/resources/lang/sl/email.php b/resources/lang/sl/email.php new file mode 100644 index 0000000..0086f04 --- /dev/null +++ b/resources/lang/sl/email.php @@ -0,0 +1,59 @@ + 'Pošlji e-pošto', + 'send_email_to' => 'Pošlji e-pošto uporabniku :name', + 'sender' => 'Pošiljatelj', + 'recipient' => 'Prejemnik', + 'cc' => 'Kp (CC)', + 'cc_help' => 'Dodatni prejemniki', + 'bcc' => 'Skp (BCC)', + 'bcc_help' => 'Skriti prejemniki', + 'subject' => 'Zadeva', + 'subject_placeholder' => 'Vnesite zadevo sporočila', + 'message' => 'Sporočilo', + 'message_placeholder' => 'Vnesite vsebino sporočila...', + 'send' => 'Pošlji', + 'cancel' => 'Prekliči', + 'email_queued' => 'E-pošta v čakalni vrsti', + 'email_queued_to' => 'E-pošta za :email je dodana v čakalno vrsto za pošiljanje.', + 'email_sent' => 'E-pošta poslana', + 'email_sent_to' => 'E-pošta je bila uspešno poslana na :email.', + 'permission_denied' => 'Dostop zavrnjen', + 'permission_denied_message' => 'Nimate dovoljenja za pošiljanje e-pošte.', + 'error' => 'Napaka', + 'send_error_message' => 'Prišlo je do napake pri pošiljanju e-pošte: :error', + 'email_form_title' => 'Pošiljanje e-pošte', + 'email_form_description' => 'Pošljite e-pošto izbranemu uporabniku', + 'your_email' => 'Vaš e-poštni naslov', + 'recipient_email' => 'Prejemnik e-pošte', + 'invalid_cc_email' => 'En ali več CC e-poštnih naslovov ni veljavnih.', + 'invalid_bcc_email' => 'En ali več BCC e-poštnih naslovov ni veljavnih.', + + // Mail Log / Outbox + 'outbox' => 'Odhodna e-pošta', + 'outbox_description' => 'Pregled vse odhodne e-pošte', + 'email_details' => 'Podrobnosti e-pošte', + 'email_body' => 'Vsebina e-pošte', + 'metadata' => 'Metapodatki', + 'user_information' => 'Podatki o uporabnikih', + 'headers' => 'Glave', + 'from' => 'Od', + 'to' => 'Za', + 'sent_at' => 'Poslano ob', + 'message_id' => 'ID sporočila', + 'status' => 'Status', + 'sent' => 'Poslano', + 'sending' => 'Pošiljanje', + 'failed' => 'Neuspešno', + 'sent_today' => 'Poslano danes', + 'sent_this_week' => 'Poslano ta teden', + 'back_to_outbox' => 'Nazaj na odhodna sporočila', + 'view_email' => 'Prikaži e-pošto', + 'no_emails' => 'Ni najdenih e-poštnih sporočil', + 'email_log' => 'Dnevnik e-pošte', + 'show_html' => 'Prikaži HTML', + 'show_preview' => 'Prikaži predogled', + 'email_preview' => 'Predogled e-pošte', + 'html_source' => 'HTML koda', +]; diff --git a/resources/lang/sr/email.php b/resources/lang/sr/email.php new file mode 100644 index 0000000..f9bb425 --- /dev/null +++ b/resources/lang/sr/email.php @@ -0,0 +1,59 @@ + 'Pošalji imejl', + 'send_email_to' => 'Pošalji imejl korisniku :name', + 'sender' => 'Pošaljilac', + 'recipient' => 'Primalac', + 'cc' => 'CC', + 'cc_help' => 'Dodatni primaoci', + 'bcc' => 'BCC', + 'bcc_help' => 'Skriveni primaoci', + 'subject' => 'Predmet', + 'subject_placeholder' => 'Unesite predmet imejla', + 'message' => 'Poruka', + 'message_placeholder' => 'Unesite sadržaj poruke...', + 'send' => 'Pošalji', + 'cancel' => 'Otkaži', + 'email_queued' => 'Imejl je u redu za slanje', + 'email_queued_to' => 'Imejl za :email je dodat u red za slanje.', + 'email_sent' => 'Imejl poslan', + 'email_sent_to' => 'Imejl poslan na :email je uspešno poslan.', + 'permission_denied' => 'Dozvola odbijena', + 'permission_denied_message' => 'Nemate dozvolu za slanje imejlova.', + 'error' => 'Greška', + 'send_error_message' => 'Došlo je do greške prilikom slanja imejla: :error', + 'email_form_title' => 'Pošalji imejl', + 'email_form_description' => 'Pošalji imejl odabranom korisniku', + 'your_email' => 'Vaša imejl adresa', + 'recipient_email' => 'Imejl adresa primaoca', + 'invalid_cc_email' => 'Jedna ili više CC imejl adresa nije validna.', + 'invalid_bcc_email' => 'Jedna ili više BCC imejl adresa nije validna.', + + // Mail Log / Outbox + 'outbox' => 'Odlazni imejlovi', + 'outbox_description' => 'Pregled svih odlaznih imejlova', + 'email_details' => 'Detalji imejla', + 'email_body' => 'Sadržaj imejla', + 'metadata' => 'Metapodaci', + 'user_information' => 'Informacije o korisnicima', + 'headers' => 'Zaglavlja', + 'from' => 'Od', + 'to' => 'Za', + 'sent_at' => 'Poslano u', + 'message_id' => 'ID poruke', + 'status' => 'Status', + 'sent' => 'Poslano', + 'sending' => 'Šalje se', + 'failed' => 'Neuspešno', + 'sent_today' => 'Poslano danas', + 'sent_this_week' => 'Poslano ove nedelje', + 'back_to_outbox' => 'Nazad na odlazne poruke', + 'view_email' => 'Prikaži imejl', + 'no_emails' => 'Nema pronađenih imejlova', + 'email_log' => 'Dnevnik imejlova', + 'show_html' => 'Prikaži HTML', + 'show_preview' => 'Prikaži pregled', + 'email_preview' => 'Pregled imejla', + 'html_source' => 'HTML kod', +]; diff --git a/resources/views/filament/pages/send-email-user.blade.php b/resources/views/filament/pages/send-email-user.blade.php new file mode 100644 index 0000000..29c94dd --- /dev/null +++ b/resources/views/filament/pages/send-email-user.blade.php @@ -0,0 +1,9 @@ + + + {{ $this->form }} + + + + \ No newline at end of file diff --git a/resources/views/mail/send-email-to-user.blade.php b/resources/views/mail/send-email-to-user.blade.php new file mode 100644 index 0000000..aa41a2b --- /dev/null +++ b/resources/views/mail/send-email-to-user.blade.php @@ -0,0 +1,97 @@ + + + + + + {{ $subject ?? __('eclipse::email.email_sent') }} + + + +
+
+

{{ config('app.name') }}

+
+ + @if($sender) +
+

{{ __('eclipse::email.sender') }}: {{ $sender->name }} ({{ $sender->email }})

+
+ @endif + +
+

{{ __('eclipse::email.message') }}:

+
{!! $messageContent !!}
+
+ + +
+ + \ No newline at end of file diff --git a/src/EclipseServiceProvider.php b/src/EclipseServiceProvider.php index 9834568..b9ac4f4 100644 --- a/src/EclipseServiceProvider.php +++ b/src/EclipseServiceProvider.php @@ -10,6 +10,8 @@ use Eclipse\Core\Console\Commands\PostComposerUpdate; use Eclipse\Core\Console\Commands\SetupReverb; use Eclipse\Core\Health\Checks\ReverbCheck; +use Eclipse\Core\Listeners\LogEmailToDatabase; +use Eclipse\Core\Listeners\SendEmailSuccessNotification; use Eclipse\Core\Models\Locale; use Eclipse\Core\Models\User; use Eclipse\Core\Models\User\Permission; @@ -23,6 +25,7 @@ use Filament\Tables\Columns\Column; use Illuminate\Auth\Events\Login; use Illuminate\Database\Eloquent\Model; +use Illuminate\Mail\Events\MessageSent; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; @@ -83,6 +86,9 @@ public function register(): self } }); + Event::listen(MessageSent::class, SendEmailSuccessNotification::class); + Event::listen(MessageSent::class, LogEmailToDatabase::class); + $this->app->register(AdminPanelProvider::class); if ($this->app->environment('local')) { diff --git a/src/Filament/Actions/SendEmailAction.php b/src/Filament/Actions/SendEmailAction.php new file mode 100644 index 0000000..ab95bb0 --- /dev/null +++ b/src/Filament/Actions/SendEmailAction.php @@ -0,0 +1,166 @@ +authorize(fn () => auth()->user()->can('sendEmail', User::class)) + ); + } + + protected static function getEmailFormSchema(): array + { + return [ + Section::make(__('eclipse::email.email_form_title')) + ->description(__('eclipse::email.email_form_description')) + ->schema([ + Grid::make() + ->schema([ + TextInput::make('sender_email') + ->label(__('eclipse::email.sender')) + ->default(fn () => auth()->user()->email) + ->disabled() + ->dehydrated(false) + ->helperText(__('eclipse::email.your_email')), + + TextInput::make('recipient_email') + ->label(__('eclipse::email.recipient')) + ->default(fn ($record = null) => $record?->email) + ->disabled() + ->dehydrated(false) + ->helperText(__('eclipse::email.recipient_email')), + ]) + ->columns(2), + + Grid::make() + ->schema([ + TagsInput::make('cc') + ->label(__('eclipse::email.cc')) + ->helperText(__('eclipse::email.cc_help')) + ->placeholder('email1@example.com') + ->splitKeys(['Enter', 'Tab', ' ', ',']) + ->nestedRecursiveRules(['email']), + + TagsInput::make('bcc') + ->label(__('eclipse::email.bcc')) + ->helperText(__('eclipse::email.bcc_help')) + ->placeholder('email1@example.com') + ->splitKeys(['Enter', 'Tab', ' ', ',']) + ->nestedRecursiveRules(['email']), + ]) + ->columns(2), + + TextInput::make('subject') + ->label(__('eclipse::email.subject')) + ->required() + ->maxLength(255) + ->placeholder(__('eclipse::email.subject_placeholder')), + + RichEditor::make('message') + ->label(__('eclipse::email.message')) + ->required() + ->placeholder(__('eclipse::email.message_placeholder')) + ->toolbarButtons([ + 'bold', + 'italic', + 'underline', + 'strike', + 'link', + 'bulletList', + 'orderedList', + 'h2', + 'h3', + ]), + + Hidden::make('recipient_id') + ->default(fn ($record = null) => $record?->id), + ]) + ->columns(1), + ]; + } + + protected static function getEmailActionClosure(): \Closure + { + return function (array $data) { + try { + Validator::make($data, [ + 'cc' => ['nullable', 'array'], + 'cc.*' => ['email'], + 'bcc' => ['nullable', 'array'], + 'bcc.*' => ['email'], + ], [ + 'cc.*.email' => __('eclipse::email.invalid_cc_email'), + 'bcc.*.email' => __('eclipse::email.invalid_bcc_email'), + ])->validate(); + + $recipient = User::findOrFail($data['recipient_id']); + + $ccEmails = ! empty($data['cc']) ? implode(',', $data['cc']) : null; + $bccEmails = ! empty($data['bcc']) ? implode(',', $data['bcc']) : null; + + Mail::queue(new SendEmailToUser( + $recipient, + $data['subject'], + $data['message'], + $ccEmails, + $bccEmails, + auth()->user() + )); + + Notification::make() + ->title(__('eclipse::email.email_queued')) + ->body(__('eclipse::email.email_queued_to', ['email' => $recipient->email])) + ->success() + ->sendToDatabase(auth()->user()) + ->broadcast([auth()->user()]); + } catch (\Illuminate\Validation\ValidationException $e) { + $errors = collect($e->errors())->flatten()->implode(' '); + + Notification::make() + ->title(__('eclipse::email.error')) + ->body(__('eclipse::email.send_error_message', ['error' => $errors])) + ->danger() + ->sendToDatabase(auth()->user()) + ->broadcast([auth()->user()]); + } catch (\Exception $e) { + Notification::make() + ->title(__('eclipse::email.error')) + ->body(__('eclipse::email.send_error_message', ['error' => $e->getMessage()])) + ->danger() + ->sendToDatabase(auth()->user()) + ->broadcast([auth()->user()]); + } + }; + } + + protected static function configureEmailAction($action) + { + return $action + ->label(fn () => __('eclipse::email.send_email')) + ->icon('heroicon-o-envelope') + ->form(static::getEmailFormSchema()) + ->action(static::getEmailActionClosure()) + ->modalHeading(fn ($record) => __('eclipse::email.send_email_to', ['name' => $record->name])) + ->modalSubmitActionLabel(__('eclipse::email.send')) + ->modalCancelActionLabel(__('eclipse::email.cancel')) + ->modalWidth('2xl') + ->modalFooterActionsAlignment('end'); + } +} diff --git a/src/Filament/Actions/SendEmailTableAction.php b/src/Filament/Actions/SendEmailTableAction.php new file mode 100644 index 0000000..fac0170 --- /dev/null +++ b/src/Filament/Actions/SendEmailTableAction.php @@ -0,0 +1,18 @@ +authorize(fn () => auth()->user()->can('sendEmail', User::class)) + ->visible(fn ($record) => ! $record->trashed()) + ); + } +} diff --git a/src/Filament/Resources/MailLogResource.php b/src/Filament/Resources/MailLogResource.php new file mode 100644 index 0000000..afafc13 --- /dev/null +++ b/src/Filament/Resources/MailLogResource.php @@ -0,0 +1,292 @@ +schema([ + Forms\Components\Section::make(__('eclipse::email.email_details')) + ->schema([ + Forms\Components\TextInput::make('from') + ->label(__('eclipse::email.from')) + ->disabled(), + Forms\Components\TextInput::make('to') + ->label(__('eclipse::email.to')) + ->disabled(), + Forms\Components\TextInput::make('cc') + ->label(__('eclipse::email.cc')) + ->disabled(), + Forms\Components\TextInput::make('bcc') + ->label(__('eclipse::email.bcc')) + ->disabled(), + Forms\Components\TextInput::make('subject') + ->label(__('eclipse::email.subject')) + ->disabled(), + Forms\Components\Textarea::make('body') + ->label(__('eclipse::email.email_body')) + ->rows(10) + ->disabled(), + ]) + ->columns(2), + + Forms\Components\Section::make(__('eclipse::email.metadata')) + ->schema([ + Forms\Components\TextInput::make('status') + ->label(__('eclipse::email.status')) + ->disabled(), + Forms\Components\DateTimePicker::make('sent_at') + ->label(__('eclipse::email.sent_at')) + ->disabled(), + Forms\Components\TextInput::make('message_id') + ->label(__('eclipse::email.message_id')) + ->disabled(), + ]) + ->columns(3), + + Forms\Components\Section::make(__('eclipse::email.headers')) + ->schema([ + Forms\Components\KeyValue::make('headers') + ->label('') + ->disabled(), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('id') + ->label('ID') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('message_id') + ->label(__('eclipse::email.message_id')) + ->limit(20) + ->tooltip(fn (MailLog $record): ?string => $record->message_id) + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('sent_at') + ->label(__('eclipse::email.sent_at')) + ->dateTime() + ->sortable(), + + Tables\Columns\TextColumn::make('from') + ->label(__('eclipse::email.from')) + ->limit(30) + ->tooltip(fn (MailLog $record): ?string => $record->from), + + Tables\Columns\TextColumn::make('to') + ->label(__('eclipse::email.to')) + ->limit(30) + ->tooltip(fn (MailLog $record): ?string => $record->to), + + Tables\Columns\TextColumn::make('cc') + ->label(__('eclipse::email.cc')) + ->limit(30) + ->tooltip(fn (MailLog $record): ?string => $record->cc) + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('bcc') + ->label(__('eclipse::email.bcc')) + ->limit(30) + ->tooltip(fn (MailLog $record): ?string => $record->bcc) + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('subject') + ->label(__('eclipse::email.subject')) + ->limit(50) + ->tooltip(fn (MailLog $record): ?string => $record->subject), + + Tables\Columns\TextColumn::make('status') + ->label(__('eclipse::email.status')) + ->badge() + ->color(fn (string $state): string => match ($state) { + 'sent' => 'success', + 'sending' => 'warning', + 'failed' => 'danger', + default => 'gray', + }), + + Tables\Columns\TextColumn::make('sender.name') + ->label('Sender') + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('recipient.name') + ->label('Recipient') + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\IconColumn::make('opened') + ->label('Opened') + ->boolean() + ->trueIcon('heroicon-o-eye') + ->falseIcon('heroicon-o-eye-slash') + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\IconColumn::make('delivered') + ->label('Delivered') + ->boolean() + ->trueIcon('heroicon-o-check-circle') + ->falseIcon('heroicon-o-x-circle') + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\IconColumn::make('complaint') + ->label('Complaint') + ->boolean() + ->trueIcon('heroicon-o-exclamation-triangle') + ->falseIcon('heroicon-o-check') + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\IconColumn::make('bounced') + ->label('Bounced') + ->boolean() + ->trueIcon('heroicon-o-arrow-uturn-left') + ->falseIcon('heroicon-o-check') + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('site.name') + ->label('Site') + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('created_at') + ->label('Created At') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('updated_at') + ->label('Updated At') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('status') + ->label(__('eclipse::email.status')) + ->options([ + 'sent' => __('eclipse::email.sent'), + 'sending' => __('eclipse::email.sending'), + 'failed' => __('eclipse::email.failed'), + ]), + + Tables\Filters\Filter::make('sent_today') + ->label(__('eclipse::email.sent_today')) + ->query(fn (Builder $query): Builder => $query->whereDate('sent_at', today())), + + Tables\Filters\Filter::make('sent_this_week') + ->label(__('eclipse::email.sent_this_week')) + ->query(fn (Builder $query): Builder => $query->whereBetween('sent_at', [now()->startOfWeek(), now()->endOfWeek()])), + ]) + ->actions([ + Tables\Actions\ViewAction::make() + ->label(__('eclipse::email.view_email')) + ->modalHeading(fn (MailLog $record): string => __('eclipse::email.view_email').': '.$record->subject) + ->modalWidth('7xl') + ->form([ + Forms\Components\Placeholder::make('email_preview') + ->label('') + ->content(fn (MailLog $record) => static::buildEmailPreview($record)) + ->columnSpanFull(), + ]), + ]) + ->defaultSort('sent_at', 'desc') + ->poll('30s') + ->emptyStateHeading(__('eclipse::email.no_emails')) + ->emptyStateDescription(__('eclipse::email.outbox_description')) + ->emptyStateIcon('heroicon-o-envelope'); + } + + protected static function buildEmailPreview(MailLog $record): HtmlString + { + $subject = htmlspecialchars($record->subject ?? '', ENT_QUOTES, 'UTF-8'); + $body = htmlspecialchars($record->body ?? '', ENT_QUOTES, 'UTF-8'); + $showPreviewText = __('eclipse::email.show_preview'); + $showHtmlText = __('eclipse::email.show_html'); + + return new HtmlString(" +
+
+ {$subject} + +
+ +
+
+ +
+
+ +
+
+
{$body}
+
+
+
+ "); + } + + public static function getPages(): array + { + return [ + 'index' => MailLogResource\Pages\ListMailLogs::route('/'), + ]; + } + + public static function getEloquentQuery(): Builder + { + $query = parent::getEloquentQuery()->latest('created_at'); + + if ($currentSite = Registry::getSite()) { + $query->where('site_id', $currentSite->id); + } + + return $query; + } + + public static function getPermissionPrefixes(): array + { + return [ + 'view_any', + 'view', + ]; + } +} diff --git a/src/Filament/Resources/MailLogResource/Pages/ListMailLogs.php b/src/Filament/Resources/MailLogResource/Pages/ListMailLogs.php new file mode 100644 index 0000000..f272ee9 --- /dev/null +++ b/src/Filament/Resources/MailLogResource/Pages/ListMailLogs.php @@ -0,0 +1,21 @@ +value; + + protected function getHeaderActions(): array + { + return [ + // No create action needed for mail logs + ]; + } +} diff --git a/src/Filament/Resources/UserResource.php b/src/Filament/Resources/UserResource.php index c8a9dc8..4d38fd9 100644 --- a/src/Filament/Resources/UserResource.php +++ b/src/Filament/Resources/UserResource.php @@ -3,6 +3,7 @@ namespace Eclipse\Core\Filament\Resources; use BezhanSalleh\FilamentShield\Contracts\HasShieldPermissions; +use Eclipse\Core\Filament\Actions\SendEmailTableAction; use Eclipse\Core\Filament\Exports\TableExport; use Eclipse\Core\Filament\Resources; use Eclipse\Core\Filament\Resources\UserResource\RelationManagers\AddressesRelationManager; @@ -214,6 +215,7 @@ public static function table(Table $table): Table Tables\Actions\ActionGroup::make([ Tables\Actions\ViewAction::make(), Tables\Actions\EditAction::make(), + SendEmailTableAction::makeAction(), Impersonate::make() ->grouped() ->redirectTo(route('filament.admin.tenant')), @@ -358,6 +360,7 @@ public static function getPermissionPrefixes(): array 'force_delete', 'force_delete_any', 'impersonate', + 'send_email', ]; } } diff --git a/src/Filament/Resources/UserResource/Pages/ViewUser.php b/src/Filament/Resources/UserResource/Pages/ViewUser.php index 39e5d76..ae24f6d 100644 --- a/src/Filament/Resources/UserResource/Pages/ViewUser.php +++ b/src/Filament/Resources/UserResource/Pages/ViewUser.php @@ -2,6 +2,7 @@ namespace Eclipse\Core\Filament\Resources\UserResource\Pages; +use Eclipse\Core\Filament\Actions\SendEmailAction; use Eclipse\Core\Filament\Resources\UserResource; use Filament\Actions; use Filament\Resources\Pages\ViewRecord; @@ -25,6 +26,7 @@ protected function getHeaderActions(): array { return [ Actions\EditAction::make(), + SendEmailAction::make(), Impersonate::make() ->record($this->getRecord()) ->redirectTo(route('filament.admin.tenant')), diff --git a/src/Listeners/LogEmailToDatabase.php b/src/Listeners/LogEmailToDatabase.php new file mode 100644 index 0000000..49f9e05 --- /dev/null +++ b/src/Listeners/LogEmailToDatabase.php @@ -0,0 +1,95 @@ +message; + $headers = $message->getHeaders(); + + $messageId = $this->extractHeaderValue($headers, 'Message-ID'); + $siteId = $this->extractHeaderValue($headers, 'X-Eclipse-Site-ID'); + $senderId = $this->extractHeaderValue($headers, 'X-Eclipse-Sender-ID'); + $recipientId = $this->extractHeaderValue($headers, 'X-Eclipse-Recipient-ID'); + + MailLog::create([ + 'site_id' => $siteId ? (int) $siteId : null, + 'message_id' => $messageId, + 'from' => $this->extractHeaderValue($headers, 'From'), + 'to' => $this->extractHeaderValue($headers, 'To'), + 'cc' => $this->extractHeaderValue($headers, 'Cc'), + 'bcc' => $this->extractHeaderValue($headers, 'Bcc'), + 'subject' => $message->getSubject() ?? '', + 'body' => $this->extractEmailBody($message), + 'headers' => $this->collectAllHeaders($headers), + 'sender_id' => $senderId ? (int) $senderId : null, + 'recipient_id' => $recipientId ? (int) $recipientId : null, + 'status' => 'sent', + 'sent_at' => now(), + ]); + } catch (\Exception $e) { + Log::error('Email logging failed: '.$e->getMessage(), [ + 'exception' => $e, + 'trace' => $e->getTraceAsString(), + ]); + } + } + + /** + * Extract a header value from the headers. + */ + private function extractHeaderValue(\Symfony\Component\Mime\Header\Headers $headers, string $headerName): ?string + { + if (! $headers->has($headerName)) { + return null; + } + + try { + return $headers->get($headerName)->getBodyAsString(); + } catch (\Exception $e) { + Log::warning("Failed to extract header {$headerName}: ".$e->getMessage()); + + return null; + } + } + + /** + * Extract the email body from the message. + */ + private function extractEmailBody(\Symfony\Component\Mime\Email $message): string + { + if (method_exists($message, 'getHtmlBody')) { + return $message->getHtmlBody() ?? ''; + } + + if (method_exists($message, 'getTextBody')) { + return $message->getTextBody() ?? ''; + } + + return ''; + } + + /** + * Collect all headers from the message. + */ + private function collectAllHeaders(\Symfony\Component\Mime\Header\Headers $headers): array + { + $allHeaders = []; + + foreach ($headers->all() as $name => $header) { + $allHeaders[$name] = $header->getBodyAsString(); + } + + return $allHeaders; + } +} diff --git a/src/Listeners/SendEmailSuccessNotification.php b/src/Listeners/SendEmailSuccessNotification.php new file mode 100644 index 0000000..87df59b --- /dev/null +++ b/src/Listeners/SendEmailSuccessNotification.php @@ -0,0 +1,52 @@ +message; + + $headers = $message->getHeaders(); + + if (! $headers->has('X-Eclipse-Email-Type') || + $headers->get('X-Eclipse-Email-Type')->getBodyAsString() !== 'SendEmailToUser') { + return; + } + + if (! $headers->has('X-Eclipse-Sender-ID')) { + return; + } + + $senderId = $headers->get('X-Eclipse-Sender-ID')->getBodyAsString(); + if (empty($senderId)) { + return; + } + + if (! $headers->has('X-Eclipse-Recipient-Email')) { + return; + } + + $recipientEmail = $headers->get('X-Eclipse-Recipient-Email')->getBodyAsString(); + + $sender = User::find($senderId); + if (! $sender) { + return; + } + + Notification::make() + ->title(__('eclipse::email.email_sent')) + ->body(__('eclipse::email.email_sent_to', ['email' => $recipientEmail])) + ->success() + ->sendToDatabase($sender) + ->broadcast([$sender]); + } +} diff --git a/src/Mail/SendEmailToUser.php b/src/Mail/SendEmailToUser.php new file mode 100644 index 0000000..8949384 --- /dev/null +++ b/src/Mail/SendEmailToUser.php @@ -0,0 +1,141 @@ +emailMessage = $this->purifyHtml($this->emailMessage); + $this->siteId = $this->siteId ?? Registry::getSite()?->id; + } + + /** + * Purify the HTML content. + */ + protected function purifyHtml(string $html): string + { + $config = HTMLPurifier_Config::createDefault(); + $config->set('HTML.Trusted', true); + $config->set('Core.Encoding', 'UTF-8'); + + return (new HTMLPurifier($config))->purify($html); + } + + /** + * Get the envelope for the email. + */ + public function envelope(): Envelope + { + $envelope = new Envelope( + subject: $this->emailSubject, + to: [$this->recipient->email] + ); + + if ($this->sender?->email) { + $envelope = $envelope->replyTo($this->sender->email, $this->sender->name); + } + + if ($ccEmails = $this->parseEmailList($this->ccEmails)) { + $envelope = $envelope->cc($ccEmails); + } + + if ($bccEmails = $this->parseEmailList($this->bccEmails)) { + $envelope = $envelope->bcc($bccEmails); + } + + return $envelope; + } + + /** + * Parse the email list. + */ + protected function parseEmailList(?string $emails): array + { + if (! $emails) { + return []; + } + + return array_filter(array_map('trim', explode(',', $emails))); + } + + /** + * Get the headers for the email. + */ + public function headers(): Headers + { + return new Headers(text: [ + 'X-Eclipse-Email-Type' => 'SendEmailToUser', + 'X-Eclipse-Sender-ID' => (string) ($this->sender?->id ?? ''), + 'X-Eclipse-Recipient-ID' => (string) $this->recipient->id, + 'X-Eclipse-Recipient-Email' => $this->recipient->email, + 'X-Eclipse-Site-ID' => (string) ($this->siteId ?? ''), + ]); + } + + /** + * Get the content for the email. + */ + public function content(): Content + { + return new Content( + view: 'eclipse::mail.send-email-to-user', + with: [ + 'recipient' => $this->recipient, + 'messageContent' => $this->emailMessage, + 'sender' => $this->sender, + 'subject' => $this->emailSubject, + ] + ); + } + + /** + * Get the attachments for the email. + */ + public function attachments(): array + { + return []; + } + + /** + * Handle the failed event. + */ + public function failed(\Throwable $exception): void + { + if (! $this->sender) { + return; + } + + Notification::make() + ->title(__('eclipse::email.error')) + ->body(__('eclipse::email.send_error_message', ['error' => $exception->getMessage()])) + ->danger() + ->sendToDatabase($this->sender) + ->broadcast([$this->sender]); + } +} diff --git a/src/Models/MailLog.php b/src/Models/MailLog.php new file mode 100644 index 0000000..22a6090 --- /dev/null +++ b/src/Models/MailLog.php @@ -0,0 +1,88 @@ + 'array', + 'attachments' => 'array', + 'data' => 'array', + 'sent_at' => 'datetime', + 'opened' => 'datetime', + 'delivered' => 'datetime', + 'complaint' => 'datetime', + 'bounced' => 'datetime', + ]; + + /** + * Get the site that the mail log belongs to. + */ + public function site(): BelongsTo + { + return $this->belongsTo(Site::class); + } + + /** + * Get the sender that the mail log belongs to. + */ + public function sender(): BelongsTo + { + return $this->belongsTo(User::class, 'sender_id'); + } + + /** + * Get the recipient that the mail log belongs to. + */ + public function recipient(): BelongsTo + { + return $this->belongsTo(User::class, 'recipient_id'); + } + + /** + * Get the factory for the model. + */ + protected static function newFactory() + { + return MailLogFactory::new(); + } +} diff --git a/src/Policies/MailLogPolicy.php b/src/Policies/MailLogPolicy.php new file mode 100644 index 0000000..09f123e --- /dev/null +++ b/src/Policies/MailLogPolicy.php @@ -0,0 +1,28 @@ +can('view_any_mail::log'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, MailLog $mailLog): bool + { + return $user->can('view_mail::log'); + } +} diff --git a/src/Policies/UserPolicy.php b/src/Policies/UserPolicy.php index 42c1211..125befe 100644 --- a/src/Policies/UserPolicy.php +++ b/src/Policies/UserPolicy.php @@ -100,4 +100,12 @@ public function impersonate(User $user): bool { return $user->can('impersonate_user'); } + + /** + * Determine whether the user can send emails to other users. + */ + public function sendEmail(User $user): bool + { + return $user->can('send_email_user'); + } } diff --git a/tests/Feature/Filament/Resources/MailLogResourceTest.php b/tests/Feature/Filament/Resources/MailLogResourceTest.php new file mode 100644 index 0000000..31357d2 --- /dev/null +++ b/tests/Feature/Filament/Resources/MailLogResourceTest.php @@ -0,0 +1,133 @@ +set_up_super_admin_and_tenant(); +}); + +test('authorized access can be allowed', function () { + $this->get(MailLogResource::getUrl()) + ->assertOk(); +}); + +test('unauthenticated users cannot access mail log index', function () { + // Clear any authenticated user + auth()->logout(); + + $this->get(MailLogResource::getUrl()) + ->assertRedirect(); // Should redirect to login +}); + +test('mail logs table page can be rendered', function () { + livewire(ListMailLogs::class)->assertSuccessful(); +}); + +test('mail logs can be searched', function () { + // Create 5 mail logs + MailLog::factory()->count(5)->create(); + + // Get first mail log + $mailLog = MailLog::first(); + + // Get second mail log + $mailLog2 = MailLog::skip(1)->first(); + + livewire(ListMailLogs::class) + ->searchTable($mailLog->subject) + ->assertSee($mailLog->subject); +}); + +test('mail logs can be filtered by status', function () { + // Create mail logs with different statuses + MailLog::factory()->create(['status' => 'sent']); + MailLog::factory()->create(['status' => 'failed']); + MailLog::factory()->create(['status' => 'sending']); + + $component = livewire(ListMailLogs::class); + + // Filter by sent status + $component->filterTable('status', 'sent') + ->assertSee('sent'); +}); + +test('mail log can be viewed', function () { + $mailLog = MailLog::factory()->create([ + 'from' => 'test@example.com', + 'to' => 'recipient@example.com', + 'subject' => 'Test Email', + 'body' => '

Test email body

', + 'status' => 'sent', + ]); + + livewire(ListMailLogs::class) + ->assertSuccessful() + ->assertTableActionExists(ViewAction::class) + ->assertTableActionEnabled(ViewAction::class, $mailLog) + ->callTableAction(ViewAction::class, $mailLog) + ->assertSee($mailLog->subject) + ->assertSee($mailLog->from) + ->assertSee($mailLog->to); +}); + +test('mail logs table shows correct columns', function () { + $mailLog = MailLog::factory()->create([ + 'sent_at' => now(), + 'from' => 'sender@example.com', + 'to' => 'recipient@example.com', + 'subject' => 'Test Subject', + 'status' => 'sent', + ]); + + livewire(ListMailLogs::class) + ->assertSee($mailLog->from) + ->assertSee($mailLog->to) + ->assertSee($mailLog->subject) + ->assertSee($mailLog->status); +}); + +test('mail logs are sorted by sent_at desc by default', function () { + $oldMailLog = MailLog::factory()->create(['sent_at' => now()->subDays(1)]); + $newMailLog = MailLog::factory()->create(['sent_at' => now()]); + + livewire(ListMailLogs::class) + ->assertSeeInOrder([$newMailLog->subject, $oldMailLog->subject]); +}); + +test('mail logs table shows empty state when no records', function () { + livewire(ListMailLogs::class) + ->assertSuccessful() + ->assertSee('No emails found'); +}); + +test('mail log view action shows headers in modal', function () { + $mailLog = MailLog::factory()->create([ + 'headers' => [ + 'From' => 'sender@example.com', + 'To' => 'recipient@example.com', + 'Subject' => 'Test Subject', + ], + ]); + + livewire(ListMailLogs::class) + ->callTableAction(ViewAction::class, $mailLog) + ->assertSee('From') + ->assertSee('To') + ->assertSee('Subject'); +}); + +test('mail log view action modal has correct heading', function () { + $mailLog = MailLog::factory()->create([ + 'subject' => 'Test Email Subject', + ]); + + livewire(ListMailLogs::class) + ->callTableAction(ViewAction::class, $mailLog) + ->assertSee(__('eclipse::email.view_email')) + ->assertSee('Test Email Subject'); +}); diff --git a/tests/Feature/UserEmailTest.php b/tests/Feature/UserEmailTest.php new file mode 100644 index 0000000..ccd7fcb --- /dev/null +++ b/tests/Feature/UserEmailTest.php @@ -0,0 +1,165 @@ +set_up_super_admin_and_tenant(); + + // Create additional permissions + Permission::firstOrCreate(['name' => 'send_email_user']); + + // Create role with email permission + $this->emailRole = Role::firstOrCreate(['name' => 'email_sender']); + $this->emailRole->givePermissionTo(['send_email_user', 'view_any_user']); + + // Create role without email permission + $this->regularRole = Role::firstOrCreate(['name' => 'regular_user']); + $this->regularRole->givePermissionTo(['view_any_user']); + + // Create users with site association + $site = \Eclipse\Core\Models\Site::first(); + + $this->authorizedUser = User::factory()->create(); + $this->authorizedUser->assignRole($this->emailRole); + $this->authorizedUser->sites()->attach($site); + + $this->unauthorizedUser = User::factory()->create(); + $this->unauthorizedUser->assignRole($this->regularRole); + $this->unauthorizedUser->sites()->attach($site); + + $this->recipientUser = User::factory()->create(); + $this->recipientUser->sites()->attach($site); +}); + +test('authorized user has send email permission', function () { + $this->actingAs($this->authorizedUser); + + expect($this->authorizedUser->can('sendEmail', User::class))->toBeTrue(); +}); + +test('unauthorized user does not have send email permission', function () { + $this->actingAs($this->unauthorizedUser); + + expect($this->unauthorizedUser->can('sendEmail', User::class))->toBeFalse(); +}); + +test('send email action requires authorization', function () { + // Test authorization through the policy directly + $this->actingAs($this->authorizedUser); + expect($this->authorizedUser->can('sendEmail', User::class))->toBeTrue(); + + $this->actingAs($this->unauthorizedUser); + expect($this->unauthorizedUser->can('sendEmail', User::class))->toBeFalse(); + + // Test that action is properly configured + $action = \Eclipse\Core\Filament\Actions\SendEmailTableAction::makeAction(); + expect($action->getName())->toBe('sendEmail'); + expect($action->getIcon())->toBe('heroicon-o-envelope'); +}); + +test('send email action visibility rules', function () { + $this->actingAs($this->authorizedUser); + + // Test that action has the proper visibility configuration + $action = \Eclipse\Core\Filament\Actions\SendEmailTableAction::makeAction(); + + // Action should be properly configured + expect($action)->not->toBeNull(); + expect($action->getName())->toBe('sendEmail'); + expect($action->getIcon())->toBe('heroicon-o-envelope'); + + // Test that trashed user detection works + expect($this->recipientUser->trashed())->toBeFalse(); + + $this->recipientUser->delete(); + expect($this->recipientUser->trashed())->toBeTrue(); +}); + +test('send email functionality queues mail', function () { + Queue::fake(); + Mail::fake(); + + $this->actingAs($this->authorizedUser); + + // Send email directly using the Mail class + $emailData = [ + 'subject' => 'Test Subject', + 'message' => 'Test message content', + 'cc' => 'cc1@example.com, cc2@example.com', + 'bcc' => 'bcc1@example.com', + ]; + + Mail::queue(new SendEmailToUser( + $this->recipientUser, + $emailData['subject'], + $emailData['message'], + $emailData['cc'], + $emailData['bcc'], + $this->authorizedUser + )); + + // Assert email was queued + Mail::assertQueued(SendEmailToUser::class, function ($mail) use ($emailData) { + return $mail->recipient->id === $this->recipientUser->id + && $mail->emailSubject === $emailData['subject'] + && $mail->emailMessage === $emailData['message'] + && $mail->ccEmails === $emailData['cc'] + && $mail->bccEmails === $emailData['bcc'] + && $mail->sender->id === $this->authorizedUser->id; + }); +}); + +test('email template renders correctly', function () { + $mail = new SendEmailToUser( + $this->recipientUser, + 'Test Subject', + 'Test message content', + 'cc@example.com', + 'bcc@example.com', + $this->authorizedUser + ); + + $view = $mail->content()->view; + $data = $mail->content()->with; + + expect($view)->toBe('eclipse::mail.send-email-to-user'); + expect($data['recipient']->id)->toBe($this->recipientUser->id); + expect($data['messageContent'])->toBe('Test message content'); + expect($data['sender']->id)->toBe($this->authorizedUser->id); + expect($data['subject'])->toBe('Test Subject'); +}); + +test('email envelope has correct recipients', function () { + $mail = new SendEmailToUser( + $this->recipientUser, + 'Test Subject', + 'Test message content', + 'cc1@example.com, cc2@example.com', + 'bcc1@example.com, bcc2@example.com', + $this->authorizedUser + ); + + $envelope = $mail->envelope(); + + expect($envelope->subject)->toBe('Test Subject'); + + // Test recipients + expect($envelope->to)->toHaveCount(1); + expect($envelope->to[0]->address)->toBe($this->recipientUser->email); + + // Test CC + expect($envelope->cc)->toHaveCount(2); + expect($envelope->cc[0]->address)->toBe('cc1@example.com'); + expect($envelope->cc[1]->address)->toBe('cc2@example.com'); + + // Test BCC + expect($envelope->bcc)->toHaveCount(2); + expect($envelope->bcc[0]->address)->toBe('bcc1@example.com'); + expect($envelope->bcc[1]->address)->toBe('bcc2@example.com'); +}); diff --git a/tests/Unit/Listeners/LogEmailToDatabaseTest.php b/tests/Unit/Listeners/LogEmailToDatabaseTest.php new file mode 100644 index 0000000..06b0bc3 --- /dev/null +++ b/tests/Unit/Listeners/LogEmailToDatabaseTest.php @@ -0,0 +1,94 @@ +subject('Hello World') + ->text('Plain text body') + ->html('

HTML body

') + ->from('from@example.com') + ->to('to@example.com') + ->cc('cc@example.com') + ->bcc('bcc@example.com'); + + $headers = $email->getHeaders(); + + // Add Message-ID header manually + $headers->add(new IdentificationHeader('Message-ID', 'test-123@example.com')); + + // Add custom application headers + $headers->addTextHeader('X-Eclipse-Site-ID', '123'); + $headers->addTextHeader('X-Eclipse-Sender-ID', '456'); + $headers->addTextHeader('X-Eclipse-Recipient-ID', '789'); + + // Create a mock SentMessage that wraps the Email + $sentMessage = Mockery::mock(LaravelSentMessage::class); + $sentMessage->shouldReceive('getOriginalMessage')->andReturn($email); + + // Invoke the listener with the MessageSent event + (new LogEmailToDatabase)->handle(new MessageSent($sentMessage, [])); + + $log = MailLog::first(); + + expect(MailLog::count())->toBe(1) + ->and($log->message_id)->toBe('') + ->and($log->site_id)->toBe(123) + ->and($log->sender_id)->toBe(456) + ->and($log->recipient_id)->toBe(789) + ->and($log->from)->toBe('from@example.com') + ->and($log->to)->toBe('to@example.com') + ->and($log->cc)->toBe('cc@example.com') + ->and($log->bcc)->toBe('bcc@example.com') + ->and($log->subject)->toBe('Hello World') + ->and($log->body)->toBe('

HTML body

') + ->and(is_array($log->headers))->toBeTrue() + ->and($log->status)->toBe('sent'); + + expect($log->sent_at)->not()->toBeNull(); +}); + +test('it handles missing headers gracefully and still logs minimum fields', function () { + // Create Email with only required From/To + $email = (new Email) + ->subject('No Headers') + ->html('

Body only

') + ->from('foo@example.com') + ->to('bar@example.com'); + + // No custom headers added + + $sentMessage = Mockery::mock(LaravelSentMessage::class); + $sentMessage->shouldReceive('getOriginalMessage')->andReturn($email); + + (new LogEmailToDatabase)->handle(new MessageSent($sentMessage, [])); + + $log = MailLog::first(); + + expect(MailLog::count())->toBe(1) + ->and($log->message_id)->toBeNull() + ->and($log->site_id)->toBeNull() + ->and($log->sender_id)->toBeNull() + ->and($log->recipient_id)->toBeNull() + ->and($log->from)->toBe('foo@example.com') + ->and($log->to)->toBe('bar@example.com') + ->and($log->cc)->toBeNull() + ->and($log->bcc)->toBeNull() + ->and($log->subject)->toBe('No Headers') + ->and($log->body)->toBe('

Body only

') + ->and(is_array($log->headers))->toBeTrue() + ->and($log->status)->toBe('sent'); + + expect($log->sent_at)->not()->toBeNull(); +});