feat: implement soft deletes, mailbox reclaims, cooldowns, and auto-cleanup

This commit is contained in:
idevakk
2026-03-06 02:39:47 +05:30
parent e79c3f79a2
commit e6fd4e6f4c
8 changed files with 176 additions and 11 deletions

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class CleanupExpiredMailboxes extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'mailboxes:cleanup';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Cleanup expired mailboxes and their associated data';
/**
* Execute the console command.
*/
public function handle()
{
$mailboxes = \App\Models\Mailbox::where('expires_at', '<=', now())->get();
$count = $mailboxes->count();
foreach ($mailboxes as $mailbox) {
$mailbox->delete(); // Triggers soft-delete and the 'deleted' event listener
}
$this->info("Cleaned up {$count} expired mailboxes.");
}
}

View File

@@ -209,9 +209,77 @@ class Mailbox extends Component
return;
}
$address = $this->createType === 'random'
? fake()->userName().'_'.rand(10, 99).'@'.$this->customDomain
: $this->customUsername.'@'.$this->customDomain;
if ($this->createType === 'random') {
do {
$address = fake()->userName().rand(10, 99).'@'.$this->customDomain;
} while (MailboxModel::withTrashed()->where('address', $address)->exists());
} else {
$address = $this->customUsername.'@'.$this->customDomain;
// Check if address already exists
$existing = MailboxModel::withTrashed()->where('address', $address)->first();
if ($existing) {
// Scenario A: Same User Reclaiming
$isOwner = (auth()->check() && $existing->user_id === auth()->id())
|| ($existing->session_id === Session::getId());
if ($isOwner) {
if ($existing->trashed()) {
$existing->restore();
}
if (now() > $existing->expires_at) {
$existing->update([
'expires_at' => now()->addDays($this->getValidityDays()),
'last_accessed_at' => now(),
]);
}
$this->currentMailboxId = $existing->id;
$this->showCreateModal = false;
$this->customUsername = '';
Session::put('last_mailbox_id', $existing->id);
return;
}
// Scenario B: Different User Claiming
if (! $existing->trashed()) {
$this->dispatch('notify', message: 'Address already in use.', type: 'danger');
return;
}
// Address is soft-deleted. Check Tier-based Cooldown
$user = auth()->user();
$hoursRequired = match (true) {
! $user => 24, // Guest
$user->isEnterprise() || $user->isAdmin() => 0,
$user->isPro() => 6,
$user->isFree() => 12,
default => 12,
};
$cooldownEndsAt = $existing->deleted_at->copy()->addHours($hoursRequired);
if (now()->lessThan($cooldownEndsAt)) {
$diff = now()->diff($cooldownEndsAt);
$parts = [];
if ($diff->d > 0) $parts[] = $diff->d . 'd';
if ($diff->h > 0) $parts[] = $diff->h . 'h';
if ($diff->i > 0) $parts[] = $diff->i . 'm';
if ($diff->s > 0 || empty($parts)) $parts[] = $diff->s . 's';
$remaining = implode(' ', $parts);
$this->dispatch('notify', message: "Address is in cooldown. Try again in {$remaining}.", type: 'warning');
return;
}
// Cooldown passed. Permanently delete the old record to sever email history.
$existing->forceDelete();
}
}
$mailbox = MailboxModel::create([
'mailbox_hash' => bin2hex(random_bytes(32)),
@@ -250,7 +318,9 @@ class Mailbox extends Component
return;
}
$address = fake()->userName().'_'.rand(10, 99).'@'.$domain->name;
do {
$address = fake()->userName().rand(10, 99).'@'.$domain->name;
} while (MailboxModel::withTrashed()->where('address', $address)->exists());
$mailbox = MailboxModel::create([
'mailbox_hash' => bin2hex(random_bytes(32)),
@@ -297,7 +367,7 @@ class Mailbox extends Component
$this->autoCreateRandomMailbox();
}
$this->isCreatingFirstMailbox = false;
// Ensure the component re-renders fully with the new data
$this->dispatch('$refresh');
}

View File

@@ -4,11 +4,12 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Mailbox extends Model
{
/** @use HasFactory<\Database\Factories\MailboxFactory> */
use HasFactory;
use HasFactory, SoftDeletes;
protected $fillable = [
'mailbox_hash',
@@ -54,6 +55,21 @@ class Mailbox extends Model
userAgent: request()->userAgent()
);
});
static::deleted(function (Mailbox $mailbox) {
// Find all associated emails
$hashes = \App\Models\Email::where('recipient_email', $mailbox->address)->pluck('unique_id_hash');
if ($hashes->isNotEmpty()) {
// Clean MongoDB documents
\App\Models\EmailBody::whereIn('unique_id_hash', $hashes)->delete();
// Clean MariaDB records
\App\Models\Email::whereIn('unique_id_hash', $hashes)->delete();
// Future Placeholder: Delete physical attachments from S3/Storage here
}
});
}
public function user()

View File

@@ -19,6 +19,10 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
\Illuminate\Support\Facades\Event::listen(\Illuminate\Auth\Events\Login::class, function (\Illuminate\Auth\Events\Login $event) {
\App\Models\Mailbox::where('session_id', \Illuminate\Support\Facades\Session::getId())
->whereNull('user_id')
->update(['user_id' => $event->user->id]);
});
}
}

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('mailboxes', function (Blueprint $table) {
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('mailboxes', function (Blueprint $table) {
$table->dropSoftDeletes();
});
}
};

View File

@@ -160,6 +160,7 @@
created_at: '{{ $currentMailbox->created_at?->toIso8601String() }}',
timeLeft: 'Never',
percent: 100,
isExpired: false,
init() {
if (!this.expiresAt) return;
@@ -174,6 +175,10 @@
if (now > end) {
this.timeLeft = 'Expired';
this.percent = 0;
if (!this.isExpired) {
this.isExpired = true;
$wire.deleteMailbox({{ $currentMailbox->id }});
}
return;
}
@@ -235,6 +240,7 @@
x-data="{
expiresAt: '{{ $mailbox->expires_at?->toIso8601String() }}',
timeLeft: 'Never',
isExpired: false,
init() {
if (!this.expiresAt) return;
this.update();
@@ -246,6 +252,10 @@
if (now > end) {
this.timeLeft = 'Expired';
if (!this.isExpired) {
this.isExpired = true;
$wire.deleteMailbox({{ $mailbox->id }});
}
return;
}

View File

@@ -2,7 +2,10 @@
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function (): void {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
Schedule::command('mailboxes:cleanup')->everyMinute();

View File

@@ -80,9 +80,6 @@ test('deleting mailbox performs soft delete and clears session', function () {
Livewire::test(Mailbox::class)
->call('deleteMailbox', $mailbox->id);
// Note: My current implementation of deleteMailbox doesn't use SoftDeletes on the model yet
// because I didn't add the trait to the Mailbox model in my implementation.
// Let me check if I should add SoftDeletes to Mailbox model.
$this->assertDatabaseMissing('mailboxes', ['id' => $mailbox->id]);
$this->assertSoftDeleted('mailboxes', ['id' => $mailbox->id]);
expect(session('current_mailbox_id'))->toBeNull();
});