Mail support testing faze
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreMailProfileRequest;
|
||||
use App\Http\Requests\UpdateMailProfileRequest;
|
||||
use App\Models\MailProfile;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class MailProfileController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function index(): Response
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreMailProfileRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateMailProfileRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\MailProfile;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TestMailProfileConnection implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $timeout = 30;
|
||||
|
||||
public function __construct(public int $mailProfileId) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$profile = MailProfile::find($this->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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class MailProfile extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name', 'active', 'host', 'port', 'encryption', 'username', 'from_address', 'from_name',
|
||||
'reply_to_address', 'reply_to_name', 'priority', 'max_daily_quota', 'emails_sent_today',
|
||||
'last_success_at', 'last_error_at', 'last_error_message', 'failover_to_id', 'test_status', 'test_checked_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'active' => '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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\MailProfile;
|
||||
use App\Models\User;
|
||||
|
||||
class MailProfilePolicy
|
||||
{
|
||||
protected function isAdmin(User $user): bool
|
||||
{
|
||||
if (app()->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
class MailSecretEncrypter
|
||||
{
|
||||
public function __construct(
|
||||
protected ?string $key = null,
|
||||
) {
|
||||
$this->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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user