diff --git a/app/Console/Commands/CleanupExpiredMailboxes.php b/app/Console/Commands/CleanupExpiredMailboxes.php new file mode 100644 index 0000000..2d64046 --- /dev/null +++ b/app/Console/Commands/CleanupExpiredMailboxes.php @@ -0,0 +1,37 @@ +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."); + } +} diff --git a/app/Livewire/Mailbox.php b/app/Livewire/Mailbox.php index 212cd0d..000f6e3 100644 --- a/app/Livewire/Mailbox.php +++ b/app/Livewire/Mailbox.php @@ -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'); } diff --git a/app/Models/Mailbox.php b/app/Models/Mailbox.php index 09af210..8c49ead 100644 --- a/app/Models/Mailbox.php +++ b/app/Models/Mailbox.php @@ -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() diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..b6efa33 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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]); + }); } } diff --git a/database/migrations/2026_03_05_202748_add_soft_deletes_to_mailboxes_table.php b/database/migrations/2026_03_05_202748_add_soft_deletes_to_mailboxes_table.php new file mode 100644 index 0000000..96eb1c6 --- /dev/null +++ b/database/migrations/2026_03_05_202748_add_soft_deletes_to_mailboxes_table.php @@ -0,0 +1,28 @@ +softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('mailboxes', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/resources/views/livewire/mailbox.blade.php b/resources/views/livewire/mailbox.blade.php index 2a06150..c5ab24f 100644 --- a/resources/views/livewire/mailbox.blade.php +++ b/resources/views/livewire/mailbox.blade.php @@ -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; } diff --git a/routes/console.php b/routes/console.php index fa9463c..4e57cdb 100644 --- a/routes/console.php +++ b/routes/console.php @@ -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(); diff --git a/tests/Feature/MailboxLivewireTest.php b/tests/Feature/MailboxLivewireTest.php index 1285c6d..865870a 100644 --- a/tests/Feature/MailboxLivewireTest.php +++ b/tests/Feature/MailboxLivewireTest.php @@ -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(); });