feat: implement soft deletes, mailbox reclaims, cooldowns, and auto-cleanup
This commit is contained in:
37
app/Console/Commands/CleanupExpiredMailboxes.php
Normal file
37
app/Console/Commands/CleanupExpiredMailboxes.php
Normal 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.");
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user