Mail support testing faze

This commit is contained in:
Simon Pocrnjič
2025-10-07 21:57:10 +02:00
parent 175111bed4
commit b9ca8244ef
18 changed files with 1279 additions and 101 deletions
@@ -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');
}
}
+52 -43
View File
@@ -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'],
];
}
}
+149
View File
@@ -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');
}
}
}
}
+75
View File
@@ -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']);
}
}
+47
View File
@@ -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);
}
}
+73
View File
@@ -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
}
}