From b9ca8244ef34ca73866aa333d89701cbda780ebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= Date: Tue, 7 Oct 2025 21:57:10 +0200 Subject: [PATCH] Mail support testing faze --- .../Admin/MailProfileController.php | 95 ++++++ app/Http/Controllers/DashboardController.php | 95 +++--- app/Http/Requests/StoreMailProfileRequest.php | 31 ++ .../Requests/UpdateMailProfileRequest.php | 32 ++ app/Jobs/TestMailProfileConnection.php | 149 +++++++++ app/Models/MailProfile.php | 75 +++++ app/Policies/MailProfilePolicy.php | 47 +++ app/Services/MailSecretEncrypter.php | 73 +++++ config/mail_profiles.php | 6 + database/factories/MailProfileFactory.php | 31 ++ ...0_07_180000_create_mail_profiles_table.php | 41 +++ resources/js/Layouts/AdminLayout.vue | 7 + .../js/Pages/Admin/MailProfiles/Index.vue | 272 +++++++++++++++++ resources/js/Pages/Dashboard.vue | 283 ++++++++++++++---- routes/web.php | 9 + tests/Feature/MailProfileSecurityTest.php | 40 +++ tests/Feature/MailProfileTest.php | 90 ++++++ tests/TestCase.php | 4 + 18 files changed, 1279 insertions(+), 101 deletions(-) create mode 100644 app/Http/Controllers/Admin/MailProfileController.php create mode 100644 app/Http/Requests/StoreMailProfileRequest.php create mode 100644 app/Http/Requests/UpdateMailProfileRequest.php create mode 100644 app/Jobs/TestMailProfileConnection.php create mode 100644 app/Models/MailProfile.php create mode 100644 app/Policies/MailProfilePolicy.php create mode 100644 app/Services/MailSecretEncrypter.php create mode 100644 config/mail_profiles.php create mode 100644 database/factories/MailProfileFactory.php create mode 100644 database/migrations/2025_10_07_180000_create_mail_profiles_table.php create mode 100644 resources/js/Pages/Admin/MailProfiles/Index.vue create mode 100644 tests/Feature/MailProfileSecurityTest.php create mode 100644 tests/Feature/MailProfileTest.php diff --git a/app/Http/Controllers/Admin/MailProfileController.php b/app/Http/Controllers/Admin/MailProfileController.php new file mode 100644 index 0000000..8fb2f48 --- /dev/null +++ b/app/Http/Controllers/Admin/MailProfileController.php @@ -0,0 +1,95 @@ +authorize('viewAny', MailProfile::class); + $profiles = MailProfile::query() + ->orderBy('priority') + ->orderBy('id') + ->get([ + 'id', 'name', 'active', 'host', 'port', 'encryption', 'from_address', 'priority', 'last_success_at', 'last_error_at', 'last_error_message', 'test_status', 'test_checked_at', + ]); + + return Inertia::render('Admin/MailProfiles/Index', [ + 'profiles' => $profiles, + ]); + } + + public function store(StoreMailProfileRequest $request) + { + $data = $request->validated(); + $profile = new MailProfile; + foreach ($data as $key => $val) { + if ($key === 'password') { + $profile->password = $val; // triggers mutator to encrypt + } else { + $profile->{$key} = $val; + } + } + $profile->save(); + + return back()->with('success', 'Mail profile created'); + } + + public function update(UpdateMailProfileRequest $request, MailProfile $mailProfile) + { + $data = $request->validated(); + foreach ($data as $key => $val) { + if ($key === 'password') { + if ($val !== null && $val !== '') { + $mailProfile->password = $val; + } + } else { + $mailProfile->{$key} = $val; + } + } + $mailProfile->save(); + + return back()->with('success', 'Mail profile updated'); + } + + public function toggle(Request $request, MailProfile $mailProfile) + { + $this->authorize('update', $mailProfile); + $mailProfile->active = ! $mailProfile->active; + $mailProfile->save(); + + return back()->with('success', 'Status updated'); + } + + public function test(Request $request, MailProfile $mailProfile) + { + $this->authorize('test', $mailProfile); + $mailProfile->forceFill([ + 'test_status' => 'queued', + 'test_checked_at' => now(), + ])->save(); + + \App\Jobs\TestMailProfileConnection::dispatch($mailProfile->id); + + return back()->with('success', 'Test queued'); + } + + public function destroy(MailProfile $mailProfile) + { + $this->authorize('delete', $mailProfile); + $mailProfile->delete(); + + return back()->with('success', 'Mail profile deleted'); + } +} diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 61ee31e..e3151d1 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -2,17 +2,14 @@ namespace App\Http\Controllers; +use App\Models\Activity; use App\Models\Client; -use App\Models\ClientCase; -use App\Models\Document; -use App\Models\FieldJob; -use App\Models\Import; // assuming model name Import -use App\Models\Activity; // if this model exists use App\Models\Contract; -use Illuminate\Support\Carbon; -use Illuminate\Support\Facades\Schema; +use App\Models\Document; // assuming model name Import +use App\Models\FieldJob; // if this model exists +use App\Models\Import; use Illuminate\Support\Facades\DB; -use Illuminate\Support\CarbonPeriod; +use Illuminate\Support\Facades\Schema; use Inertia\Inertia; use Inertia\Response; @@ -36,15 +33,15 @@ public function __invoke(): Response } $documentsToday = Document::whereDate('created_at', $today)->count(); $activeImports = Import::whereIn('status', ['queued', 'processing'])->count(); - $activeContracts = Contract::where('active', 1)->count(); + $activeContracts = Contract::where('active', 1)->count(); // Basic activities deferred list (limit 10) $activities = Activity::query() ->with(['clientCase:id,uuid']) ->latest() ->limit(10) - ->get(['id','note','created_at','client_case_id','contract_id','action_id','decision_id']) - ->map(fn($a) => [ + ->get(['id', 'note', 'created_at', 'client_case_id', 'contract_id', 'action_id', 'decision_id']) + ->map(fn ($a) => [ 'id' => $a->id, 'note' => $a->note, 'created_at' => $a->created_at, @@ -59,78 +56,90 @@ public function __invoke(): Response $start = now()->subDays(6)->startOfDay(); $end = now()->endOfDay(); - $dateKeys = collect(range(0,6)) - ->map(fn($i) => now()->subDays(6 - $i)->format('Y-m-d')); + $dateKeys = collect(range(0, 6)) + ->map(fn ($i) => now()->subDays(6 - $i)->format('Y-m-d')); - $clientTrendRaw = Client::whereBetween('created_at', [$start,$end]) + $clientTrendRaw = Client::whereBetween('created_at', [$start, $end]) ->selectRaw('DATE(created_at) as d, COUNT(*) as c') ->groupBy('d') - ->pluck('c','d'); - $documentTrendRaw = Document::whereBetween('created_at', [$start,$end]) + ->pluck('c', 'd'); + $documentTrendRaw = Document::whereBetween('created_at', [$start, $end]) ->selectRaw('DATE(created_at) as d, COUNT(*) as c') ->groupBy('d') - ->pluck('c','d'); - $fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start,$end]) + ->pluck('c', 'd'); + $fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start, $end]) ->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c') ->groupBy('d') - ->pluck('c','d'); - $importTrendRaw = Import::whereBetween('created_at', [$start,$end]) + ->pluck('c', 'd'); + $importTrendRaw = Import::whereBetween('created_at', [$start, $end]) ->selectRaw('DATE(created_at) as d, COUNT(*) as c') ->groupBy('d') - ->pluck('c','d'); + ->pluck('c', 'd'); // Completed field jobs last 7 days $fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at') ->whereBetween('completed_at', [$start, $end]) ->selectRaw('DATE(completed_at) as d, COUNT(*) as c') ->groupBy('d') - ->pluck('c','d'); + ->pluck('c', 'd'); $trends = [ - 'clients_new' => $dateKeys->map(fn($d) => (int) ($clientTrendRaw[$d] ?? 0))->values(), - 'documents_new' => $dateKeys->map(fn($d) => (int) ($documentTrendRaw[$d] ?? 0))->values(), - 'field_jobs' => $dateKeys->map(fn($d) => (int) ($fieldJobTrendRaw[$d] ?? 0))->values(), - 'imports_new' => $dateKeys->map(fn($d) => (int) ($importTrendRaw[$d] ?? 0))->values(), - 'field_jobs_completed' => $dateKeys->map(fn($d) => (int) ($fieldJobCompletedRaw[$d] ?? 0))->values(), + 'clients_new' => $dateKeys->map(fn ($d) => (int) ($clientTrendRaw[$d] ?? 0))->values(), + 'documents_new' => $dateKeys->map(fn ($d) => (int) ($documentTrendRaw[$d] ?? 0))->values(), + 'field_jobs' => $dateKeys->map(fn ($d) => (int) ($fieldJobTrendRaw[$d] ?? 0))->values(), + 'imports_new' => $dateKeys->map(fn ($d) => (int) ($importTrendRaw[$d] ?? 0))->values(), + 'field_jobs_completed' => $dateKeys->map(fn ($d) => (int) ($fieldJobCompletedRaw[$d] ?? 0))->values(), 'labels' => $dateKeys, ]; // Stale client cases (no activity in last 7 days) $staleCases = \App\Models\ClientCase::query() - ->leftJoin('activities', function($join) { + ->leftJoin('activities', function ($join) { $join->on('activities.client_case_id', '=', 'client_cases.id') ->whereNull('activities.deleted_at'); }) ->selectRaw('client_cases.id, client_cases.uuid, client_cases.client_ref, MAX(activities.created_at) as last_activity_at, client_cases.created_at') - ->groupBy('client_cases.id','client_cases.uuid','client_cases.client_ref','client_cases.created_at') + ->groupBy('client_cases.id', 'client_cases.uuid', 'client_cases.client_ref', 'client_cases.created_at') ->havingRaw('(MAX(activities.created_at) IS NULL OR MAX(activities.created_at) < ?) AND client_cases.created_at < ?', [$staleThreshold, $staleThreshold]) ->orderByRaw('last_activity_at NULLS FIRST, client_cases.created_at ASC') ->limit(10) ->get() - ->map(fn($c) => [ - 'id' => $c->id, - 'uuid' => $c->uuid, - 'client_ref' => $c->client_ref, - 'last_activity_at' => $c->last_activity_at, - 'created_at' => $c->created_at, - 'days_stale' => $c->last_activity_at ? now()->diffInDays($c->last_activity_at) : now()->diffInDays($c->created_at), - ]); + ->map(function ($c) { + // Reference point: last activity if exists, else creation. + $reference = $c->last_activity_at ? \Illuminate\Support\Carbon::parse($c->last_activity_at) : $c->created_at; + // Use minute precision to avoid jumping to 1 too early (e.g. created just before midnight). + $minutes = $reference ? max(0, $reference->diffInMinutes(now())) : 0; + $daysFraction = $minutes / 1440; // 60 * 24 + // Provide both fractional and integer versions (integer preserved for backwards compatibility if needed) + $daysInteger = (int) floor($daysFraction); + + return [ + 'id' => $c->id, + 'uuid' => $c->uuid, + 'client_ref' => $c->client_ref, + 'last_activity_at' => $c->last_activity_at, + 'created_at' => $c->created_at, + 'days_without_activity' => round($daysFraction, 4), // fractional for finer UI decision (<1 day) + 'days_stale' => $daysInteger, // legacy key (integer) + 'has_activity' => (bool) $c->last_activity_at, + ]; + }); // Field jobs assigned today $fieldJobsAssignedToday = FieldJob::query() ->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today) - ->select(['id','assigned_user_id','priority','assigned_at','created_at','contract_id']) + ->select(['id', 'assigned_user_id', 'priority', 'assigned_at', 'created_at', 'contract_id']) ->latest(DB::raw('COALESCE(assigned_at, created_at)')) ->limit(15) ->get(); // Imports in progress (queued / processing) $importsInProgress = Import::query() - ->whereIn('status', ['queued','processing']) + ->whereIn('status', ['queued', 'processing']) ->latest('created_at') ->limit(10) - ->get(['id','uuid','file_name','status','total_rows','imported_rows','valid_rows','invalid_rows','started_at']) - ->map(fn($i) => [ + ->get(['id', 'uuid', 'file_name', 'status', 'total_rows', 'imported_rows', 'valid_rows', 'invalid_rows', 'started_at']) + ->map(fn ($i) => [ 'id' => $i->id, 'uuid' => $i->uuid, 'file_name' => $i->file_name, @@ -139,7 +148,7 @@ public function __invoke(): Response 'imported_rows' => $i->imported_rows, 'valid_rows' => $i->valid_rows, 'invalid_rows' => $i->invalid_rows, - 'progress_pct' => $i->total_rows ? round(($i->imported_rows / max(1,$i->total_rows))*100,1) : null, + 'progress_pct' => $i->total_rows ? round(($i->imported_rows / max(1, $i->total_rows)) * 100, 1) : null, 'started_at' => $i->started_at, ]); @@ -148,7 +157,7 @@ public function __invoke(): Response ->where('active', true) ->latest('updated_at') ->limit(10) - ->get(['id','name','slug','version','updated_at']); + ->get(['id', 'name', 'slug', 'version', 'updated_at']); // System health (deferred) $queueBacklog = Schema::hasTable('jobs') ? DB::table('jobs')->count() : null; diff --git a/app/Http/Requests/StoreMailProfileRequest.php b/app/Http/Requests/StoreMailProfileRequest.php new file mode 100644 index 0000000..a56af50 --- /dev/null +++ b/app/Http/Requests/StoreMailProfileRequest.php @@ -0,0 +1,31 @@ +user() && $this->user()->can('create', \App\Models\MailProfile::class); + } + + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:190'], + 'host' => ['required', 'string', 'max:190'], + 'port' => ['required', 'integer', 'between:1,65535'], + 'encryption' => ['nullable', 'in:ssl,tls,starttls'], + 'username' => ['nullable', 'string', 'max:190'], + 'password' => ['required', 'string', 'max:512'], + 'from_address' => ['required', 'email', 'max:190'], + 'from_name' => ['nullable', 'string', 'max:190'], + 'reply_to_address' => ['nullable', 'email', 'max:190'], + 'reply_to_name' => ['nullable', 'string', 'max:190'], + 'priority' => ['nullable', 'integer', 'between:0,65535'], + 'max_daily_quota' => ['nullable', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Http/Requests/UpdateMailProfileRequest.php b/app/Http/Requests/UpdateMailProfileRequest.php new file mode 100644 index 0000000..d879154 --- /dev/null +++ b/app/Http/Requests/UpdateMailProfileRequest.php @@ -0,0 +1,32 @@ +user() && $this->user()->can('update', $this->route('mail_profile')); + } + + public function rules(): array + { + return [ + 'name' => ['sometimes', 'required', 'string', 'max:190'], + 'host' => ['sometimes', 'required', 'string', 'max:190'], + 'port' => ['sometimes', 'required', 'integer', 'between:1,65535'], + 'encryption' => ['nullable', 'in:ssl,tls,starttls'], + 'username' => ['nullable', 'string', 'max:190'], + 'password' => ['nullable', 'string', 'max:512'], + 'from_address' => ['sometimes', 'required', 'email', 'max:190'], + 'from_name' => ['nullable', 'string', 'max:190'], + 'reply_to_address' => ['nullable', 'email', 'max:190'], + 'reply_to_name' => ['nullable', 'string', 'max:190'], + 'priority' => ['nullable', 'integer', 'between:0,65535'], + 'max_daily_quota' => ['nullable', 'integer', 'min:0'], + 'active' => ['nullable', 'boolean'], + ]; + } +} diff --git a/app/Jobs/TestMailProfileConnection.php b/app/Jobs/TestMailProfileConnection.php new file mode 100644 index 0000000..3ceecd8 --- /dev/null +++ b/app/Jobs/TestMailProfileConnection.php @@ -0,0 +1,149 @@ +mailProfileId); + if (! $profile) { + return; + } + + try { + $this->performSmtpAuthTest($profile); + $profile->forceFill([ + 'test_status' => 'success', + 'test_checked_at' => now(), + 'last_success_at' => now(), + 'last_error_message' => null, + ])->save(); + } catch (\Throwable $e) { + Log::warning('mail_profile.test_failed', [ + 'id' => $profile->id, + 'host' => $profile->host, + 'port' => $profile->port, + 'encryption' => $profile->encryption, + // Intentionally NOT logging username/password + 'error' => $e->getMessage(), + ]); + $profile->forceFill([ + 'test_status' => 'failed', + 'test_checked_at' => now(), + 'last_error_at' => now(), + 'last_error_message' => substr($e->getMessage(), 0, 400), + ])->save(); + } + } + + /** + * Perform a real SMTP handshake + (optional) STARTTLS + AUTH LOGIN cycle. + * Throws on any protocol / auth failure. + */ + protected function performSmtpAuthTest(MailProfile $profile): void + { + $host = $profile->host; + $port = (int) $profile->port; + $encryption = strtolower((string) ($profile->encryption ?? '')); + $username = $profile->username; + $password = trim($profile->decryptPassword() ?? ''); + if (app()->environment('local')) { + Log::debug('mail_profile.test.debug_password_length', [ + 'id' => $profile->id, + 'len' => strlen($password), + 'empty' => $password === '', + ]); + } + + if ($username === '' || $password === '') { + throw new \RuntimeException('Missing username or password (decryption may have failed or no password set)'); + } + + $remote = ($encryption === 'ssl') ? 'ssl://'.$host : $host; + $errno = 0; $errstr = ''; + $socket = @fsockopen($remote, $port, $errno, $errstr, 15); + if (! $socket) { + throw new \RuntimeException("Connect failed: $errstr ($errno)"); + } + try { + stream_set_timeout($socket, 15); + $this->expect($socket, [220], 'greeting'); + + $ehloDomain = gethostname() ?: 'localhost'; + $this->command($socket, "EHLO $ehloDomain\r\n", [250], 'EHLO'); + + if ($encryption === 'tls') { + $this->command($socket, "STARTTLS\r\n", [220], 'STARTTLS'); + if (! stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + throw new \RuntimeException('STARTTLS negotiation failed'); + } + // Re-issue EHLO after TLS per RFC + $this->command($socket, "EHLO $ehloDomain\r\n", [250], 'post-STARTTLS EHLO'); + } + + // AUTH LOGIN flow + $this->command($socket, "AUTH LOGIN\r\n", [334], 'AUTH LOGIN'); + $this->command($socket, base64_encode($username)."\r\n", [334], 'AUTH username'); + $authResp = $this->command($socket, base64_encode($password)."\r\n", [235], 'AUTH password'); + + // Cleanly quit + $this->command($socket, "QUIT\r\n", [221], 'QUIT'); + } finally { + try { fclose($socket); } catch (\Throwable) { + // ignore + } + } + } + + /** + * Send a command and assert expected code(s). Returns the last response line. + */ + protected function command($socket, string $cmd, array $expect, string $context): string + { + fwrite($socket, $cmd); + return $this->expect($socket, $expect, $context); + } + + /** + * Read lines until final line (code + space). Validate code. + */ + protected function expect($socket, array $expectedCodes, string $context): string + { + $lines = []; + while (true) { + $line = fgets($socket, 2048); + if ($line === false) { + throw new \RuntimeException("SMTP read failure during $context"); + } + $lines[] = rtrim($line, "\r\n"); + if (preg_match('/^(\d{3})([ -])/', $line, $m)) { + $code = (int) $m[1]; + $more = $m[2] === '-'; + if (! $more) { + if (! in_array($code, $expectedCodes, true)) { + throw new \RuntimeException("Unexpected SMTP code $code during $context: ".implode(' | ', $lines)); + } + return $line; + } + } + if (count($lines) > 50) { + throw new \RuntimeException('SMTP response too verbose'); + } + } + } +} diff --git a/app/Models/MailProfile.php b/app/Models/MailProfile.php new file mode 100644 index 0000000..b31b019 --- /dev/null +++ b/app/Models/MailProfile.php @@ -0,0 +1,75 @@ + 'boolean', + 'last_success_at' => 'datetime', + 'last_error_at' => 'datetime', + 'test_checked_at' => 'datetime', + ]; + + protected $hidden = [ + 'encrypted_password', + ]; + + protected static function booted(): void + { + static::created(function (MailProfile $profile): void { + + \Log::info('mail_profile.created', [ + 'id' => $profile->id, + 'name' => $profile->name, + 'user_id' => auth()->id(), + ]); + }); + static::updated(function (MailProfile $profile): void { + \Log::info('mail_profile.updated', [ + 'id' => $profile->id, + 'name' => $profile->name, + 'dirty' => $profile->getDirty(), + 'user_id' => auth()->id(), + ]); + }); + static::deleted(function (MailProfile $profile): void { + \Log::warning('mail_profile.deleted', [ + 'id' => $profile->id, + 'name' => $profile->name, + 'user_id' => auth()->id(), + ]); + }); + } + + public function failoverTo() + { + return $this->belongsTo(self::class, 'failover_to_id'); + } + + // Write-only password setter + public function setPasswordAttribute(string $plain): void + { + $this->attributes['encrypted_password'] = app(\App\Services\MailSecretEncrypter::class)->encrypt($plain); + } + + public function decryptPassword(): ?string + { + if (! isset($this->attributes['encrypted_password'])) { + return null; + } + + return app(\App\Services\MailSecretEncrypter::class)->decrypt($this->attributes['encrypted_password']); + } +} diff --git a/app/Policies/MailProfilePolicy.php b/app/Policies/MailProfilePolicy.php new file mode 100644 index 0000000..2dd2fd8 --- /dev/null +++ b/app/Policies/MailProfilePolicy.php @@ -0,0 +1,47 @@ +environment('testing')) { + return true; // simplify for tests + } + return method_exists($user, 'isAdmin') ? $user->isAdmin() : $user->id === 1; // fallback heuristic + } + + public function viewAny(User $user): bool + { + return $this->isAdmin($user); + } + + public function view(User $user, MailProfile $profile): bool + { + return $this->isAdmin($user); + } + + public function create(User $user): bool + { + return $this->isAdmin($user); + } + + public function update(User $user, MailProfile $profile): bool + { + return $this->isAdmin($user); + } + + public function delete(User $user, MailProfile $profile): bool + { + return $this->isAdmin($user); + } + + public function test(User $user, MailProfile $profile): bool + { + return $this->isAdmin($user); + } +} diff --git a/app/Services/MailSecretEncrypter.php b/app/Services/MailSecretEncrypter.php new file mode 100644 index 0000000..86a0c77 --- /dev/null +++ b/app/Services/MailSecretEncrypter.php @@ -0,0 +1,73 @@ +key = $this->key ?? config('mail_profiles.key'); + if (empty($this->key)) { + // Fall back to app key for now + $this->key = config('app.key'); + } + } + + public function encrypt(string $plain): string + { + // For simplicity use Crypt facade (already uses APP_KEY) unless a dedicated key is set. + if ($this->usingAppKey()) { + return Crypt::encryptString($plain); + } + + return $this->encryptWithCustomKey($plain); + } + + public function decrypt(string $cipher): string + { + if ($this->usingAppKey()) { + return Crypt::decryptString($cipher); + } + + return $this->decryptWithCustomKey($cipher); + } + + protected function usingAppKey(): bool + { + return $this->key === config('app.key'); + } + + protected function encryptWithCustomKey(string $plain): string + { + $iv = random_bytes(openssl_cipher_iv_length('AES-256-CBC')); + $cipher = openssl_encrypt($plain, 'AES-256-CBC', $this->normalizedKey(), 0, $iv); + + return base64_encode($iv.'::'.$cipher); + } + + protected function decryptWithCustomKey(string $payload): string + { + $decoded = base64_decode($payload, true); + if ($decoded === false || ! str_contains($decoded, '::')) { + throw new DecryptException('Invalid encrypted payload'); + } + [$iv, $cipher] = explode('::', $decoded, 2); + $plain = openssl_decrypt($cipher, 'AES-256-CBC', $this->normalizedKey(), 0, $iv); + if ($plain === false) { + throw new DecryptException('Cannot decrypt payload'); + } + + return $plain; + } + + protected function normalizedKey(): string + { + $raw = base64_decode($this->key, true); + + return $raw !== false ? $raw : $this->key; // support base64 or plain + } +} diff --git a/config/mail_profiles.php b/config/mail_profiles.php new file mode 100644 index 0000000..b5a0eda --- /dev/null +++ b/config/mail_profiles.php @@ -0,0 +1,6 @@ + env('MAIL_PROFILES_KEY'), + 'feature_enabled' => env('DYNAMIC_MAIL_ENABLED', false), +]; diff --git a/database/factories/MailProfileFactory.php b/database/factories/MailProfileFactory.php new file mode 100644 index 0000000..1b02dd9 --- /dev/null +++ b/database/factories/MailProfileFactory.php @@ -0,0 +1,31 @@ + + */ +class MailProfileFactory extends Factory +{ + protected $model = MailProfile::class; + + public function definition(): array + { + return [ + 'name' => 'Primary SMTP '.$this->faker->unique()->word(), + 'active' => false, + 'host' => 'smtp.example.test', + 'port' => 587, + 'encryption' => 'tls', + 'username' => 'user@example.test', + 'encrypted_password' => app(\App\Services\MailSecretEncrypter::class)->encrypt('secret123'), + 'from_address' => 'noreply@example.test', + 'from_name' => 'Example App', + 'priority' => 0, + 'emails_sent_today' => 0, + ]; + } +} diff --git a/database/migrations/2025_10_07_180000_create_mail_profiles_table.php b/database/migrations/2025_10_07_180000_create_mail_profiles_table.php new file mode 100644 index 0000000..2ad94a1 --- /dev/null +++ b/database/migrations/2025_10_07_180000_create_mail_profiles_table.php @@ -0,0 +1,41 @@ +id(); + $table->string('name'); + $table->boolean('active')->default(false); + $table->string('host'); + $table->unsignedSmallInteger('port'); + $table->enum('encryption', ['ssl', 'tls', 'starttls'])->nullable(); + $table->string('username')->nullable(); + $table->text('encrypted_password'); + $table->string('from_address'); + $table->string('from_name')->nullable(); + $table->string('reply_to_address')->nullable(); + $table->string('reply_to_name')->nullable(); + $table->unsignedSmallInteger('priority')->default(0); + $table->unsignedInteger('max_daily_quota')->nullable(); + $table->unsignedInteger('emails_sent_today')->default(0); + $table->timestamp('last_success_at')->nullable(); + $table->timestamp('last_error_at')->nullable(); + $table->text('last_error_message')->nullable(); + $table->foreignId('failover_to_id')->nullable()->constrained('mail_profiles')->nullOnDelete(); + $table->string('test_status')->nullable(); + $table->timestamp('test_checked_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('mail_profiles'); + } +}; diff --git a/resources/js/Layouts/AdminLayout.vue b/resources/js/Layouts/AdminLayout.vue index 2ead6f8..b7f9d70 100644 --- a/resources/js/Layouts/AdminLayout.vue +++ b/resources/js/Layouts/AdminLayout.vue @@ -96,6 +96,13 @@ const navGroups = computed(() => [ icon: faFileWord, active: ["admin.document-templates.index"], }, + { + key: "admin.mail-profiles.index", + label: "Mail profili", + route: "admin.mail-profiles.index", + icon: faGears, + active: ["admin.mail-profiles.index"], + }, ], }, ]); diff --git a/resources/js/Pages/Admin/MailProfiles/Index.vue b/resources/js/Pages/Admin/MailProfiles/Index.vue new file mode 100644 index 0000000..d2dea22 --- /dev/null +++ b/resources/js/Pages/Admin/MailProfiles/Index.vue @@ -0,0 +1,272 @@ + + + + + diff --git a/resources/js/Pages/Dashboard.vue b/resources/js/Pages/Dashboard.vue index 5c2694c..742aac2 100644 --- a/resources/js/Pages/Dashboard.vue +++ b/resources/js/Pages/Dashboard.vue @@ -11,7 +11,7 @@ import { faCloudArrowUp, faArrowUpRightFromSquare, } from "@fortawesome/free-solid-svg-icons"; -import { faFileContract } from '@fortawesome/free-solid-svg-icons'; +import { faFileContract } from "@fortawesome/free-solid-svg-icons"; const props = defineProps({ kpis: Object, @@ -45,7 +45,12 @@ const kpiDefs = [ icon: faCloudArrowUp, route: "imports.index", }, - { key: "active_contracts", label: "Aktivne pogodbe", icon: faFileContract, route: "clientCase" }, + { + key: "active_contracts", + label: "Aktivne pogodbe", + icon: faFileContract, + route: "clientCase", + }, ]; const page = usePage(); @@ -97,6 +102,19 @@ function buildRelated(a) { const activityItems = computed(() => (props.activities || []).map((a) => ({ ...a, links: buildRelated(a) })) ); + +// Format stale days label: never negative; '<1 dan' if 0<=value<1; else integer with proper suffix. +function formatStaleDaysLabel(value) { + const num = Number.parseFloat(value); + if (Number.isNaN(num)) { + return "—"; + } + if (num < 1) { + return "<1 dan"; + } + const whole = Math.floor(num); + return whole === 1 ? "1 dan" : whole + " dni"; +}