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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$address = $this->createType === 'random'
|
if ($this->createType === 'random') {
|
||||||
? fake()->userName().'_'.rand(10, 99).'@'.$this->customDomain
|
do {
|
||||||
: $this->customUsername.'@'.$this->customDomain;
|
$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 = MailboxModel::create([
|
||||||
'mailbox_hash' => bin2hex(random_bytes(32)),
|
'mailbox_hash' => bin2hex(random_bytes(32)),
|
||||||
@@ -250,7 +318,9 @@ class Mailbox extends Component
|
|||||||
return;
|
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 = MailboxModel::create([
|
||||||
'mailbox_hash' => bin2hex(random_bytes(32)),
|
'mailbox_hash' => bin2hex(random_bytes(32)),
|
||||||
@@ -297,7 +367,7 @@ class Mailbox extends Component
|
|||||||
$this->autoCreateRandomMailbox();
|
$this->autoCreateRandomMailbox();
|
||||||
}
|
}
|
||||||
$this->isCreatingFirstMailbox = false;
|
$this->isCreatingFirstMailbox = false;
|
||||||
|
|
||||||
// Ensure the component re-renders fully with the new data
|
// Ensure the component re-renders fully with the new data
|
||||||
$this->dispatch('$refresh');
|
$this->dispatch('$refresh');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
class Mailbox extends Model
|
class Mailbox extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\MailboxFactory> */
|
/** @use HasFactory<\Database\Factories\MailboxFactory> */
|
||||||
use HasFactory;
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'mailbox_hash',
|
'mailbox_hash',
|
||||||
@@ -54,6 +55,21 @@ class Mailbox extends Model
|
|||||||
userAgent: request()->userAgent()
|
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()
|
public function user()
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
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() }}',
|
created_at: '{{ $currentMailbox->created_at?->toIso8601String() }}',
|
||||||
timeLeft: 'Never',
|
timeLeft: 'Never',
|
||||||
percent: 100,
|
percent: 100,
|
||||||
|
isExpired: false,
|
||||||
init() {
|
init() {
|
||||||
if (!this.expiresAt) return;
|
if (!this.expiresAt) return;
|
||||||
|
|
||||||
@@ -174,6 +175,10 @@
|
|||||||
if (now > end) {
|
if (now > end) {
|
||||||
this.timeLeft = 'Expired';
|
this.timeLeft = 'Expired';
|
||||||
this.percent = 0;
|
this.percent = 0;
|
||||||
|
if (!this.isExpired) {
|
||||||
|
this.isExpired = true;
|
||||||
|
$wire.deleteMailbox({{ $currentMailbox->id }});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +240,7 @@
|
|||||||
x-data="{
|
x-data="{
|
||||||
expiresAt: '{{ $mailbox->expires_at?->toIso8601String() }}',
|
expiresAt: '{{ $mailbox->expires_at?->toIso8601String() }}',
|
||||||
timeLeft: 'Never',
|
timeLeft: 'Never',
|
||||||
|
isExpired: false,
|
||||||
init() {
|
init() {
|
||||||
if (!this.expiresAt) return;
|
if (!this.expiresAt) return;
|
||||||
this.update();
|
this.update();
|
||||||
@@ -246,6 +252,10 @@
|
|||||||
|
|
||||||
if (now > end) {
|
if (now > end) {
|
||||||
this.timeLeft = 'Expired';
|
this.timeLeft = 'Expired';
|
||||||
|
if (!this.isExpired) {
|
||||||
|
this.isExpired = true;
|
||||||
|
$wire.deleteMailbox({{ $mailbox->id }});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\Schedule;
|
||||||
|
|
||||||
Artisan::command('inspire', function (): void {
|
Artisan::command('inspire', function (): void {
|
||||||
$this->comment(Inspiring::quote());
|
$this->comment(Inspiring::quote());
|
||||||
})->purpose('Display an 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)
|
Livewire::test(Mailbox::class)
|
||||||
->call('deleteMailbox', $mailbox->id);
|
->call('deleteMailbox', $mailbox->id);
|
||||||
|
|
||||||
// Note: My current implementation of deleteMailbox doesn't use SoftDeletes on the model yet
|
$this->assertSoftDeleted('mailboxes', ['id' => $mailbox->id]);
|
||||||
// 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]);
|
|
||||||
expect(session('current_mailbox_id'))->toBeNull();
|
expect(session('current_mailbox_id'))->toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user