Files
imail/app/Livewire/Mailbox.php

495 lines
16 KiB
PHP

<?php
namespace App\Livewire;
use App\Jobs\TrackAnalytics;
use App\Models\Domain;
use App\Models\Email;
use App\Models\EmailBody;
use App\Models\Mailbox as MailboxModel;
use Illuminate\Support\Facades\Session;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('components.layouts.app')]
class Mailbox extends Component
{
use WithPagination;
public $currentMailboxId = null;
public $activeFolder = 'inbox';
public $selectedEmailId = null;
public $search = '';
public $viewMode = 'text'; // text | html
public $allowRemoteContent = false;
// Create State
public $showCreateModal = false;
public $createType = 'random'; // random | custom
public $customUsername = '';
public $customDomain = '';
public bool $isCreatingFirstMailbox = false;
public function mount()
{
$this->customDomain = Domain::where('is_active', true)->first()?->name ?? 'imail.app';
// Load current mailbox from session if exists
$savedId = Session::get('current_mailbox_id');
if ($savedId && $this->getActiveMailboxesProperty()->contains('id', $savedId)) {
$this->currentMailboxId = $savedId;
} else {
$this->currentMailboxId = $this->getActiveMailboxesProperty()->first()?->id;
}
// Auto-create a random mailbox if none exist (first-time visitor)
if ($this->getActiveMailboxesProperty()->isEmpty()) {
$this->isCreatingFirstMailbox = true;
}
}
public function getActiveMailboxesProperty()
{
return MailboxModel::query()
->where(function ($query) {
$query->where('session_id', Session::getId());
if (auth()->check()) {
$query->orWhere('user_id', auth()->id());
}
})
->where('is_blocked', false)
->get();
}
public function getAvailableDomainsProperty()
{
return Domain::query()
->where('is_active', true)
->where('is_archived', false)
->accessibleBy(auth()->user())
->get();
}
/**
* Get Reverb/Echo event listeners for the current mailbox domain.
*
* @return array<string, string>
*/
public function getListeners(): array
{
$currentMailbox = $this->active_mailboxes->firstWhere('id', $this->currentMailboxId);
$domain = $currentMailbox ? explode('@', $currentMailbox->address)[1] ?? '' : '';
if (empty($domain)) {
return [];
}
return [
"echo:mailbox.{$domain},.new.email" => 'onNewEmail',
];
}
public function onNewEmail(array $eventData): void
{
$sender = ($eventData['sender_name'] ?? null) ?: ($eventData['sender_email'] ?? 'Unknown');
$subject = ($eventData['subject'] ?? null) ?: '(No Subject)';
$this->dispatch('notify',
message: "Sender: {$sender}\nSubject: {$subject}",
type: 'info'
);
// Simply refresh the list to pick up the new email from MariaDB
// Since we order by received_at DESC, it will appear on top.
$this->dispatch('$refresh');
}
public function getEmailsProperty()
{
$currentMailbox = $this->active_mailboxes->firstWhere('id', $this->currentMailboxId);
if (! $currentMailbox) {
return Email::query()->whereRaw('1 = 0')->paginate(10);
}
return Email::query()
->where('recipient_email', $currentMailbox->address)
->when($this->search, function ($query) {
$query->where(function ($q) {
$q->where('subject', 'like', "%{$this->search}%")
->orWhere('sender_email', 'like', "%{$this->search}%")
->orWhere('sender_name', 'like', "%{$this->search}%");
});
})
->orderByDesc('received_at')
->paginate(10);
}
public function getUnreadCountProperty(): int
{
$currentMailbox = $this->active_mailboxes->firstWhere('id', $this->currentMailboxId);
if (! $currentMailbox) {
return 0;
}
return Email::where('recipient_email', $currentMailbox->address)
->where('is_read', false)
->count();
}
public function selectEmail($id)
{
$this->selectedEmailId = $id;
$this->viewMode = 'text';
$this->allowRemoteContent = false;
$email = Email::find($id);
if ($email) {
if (! $email->is_read) {
$email->update(['is_read' => true]);
}
// Track analytics for reading email
$currentMailbox = $this->active_mailboxes->firstWhere('id', $this->currentMailboxId);
if ($currentMailbox) {
TrackAnalytics::dispatch(
eventType: 'email_read',
mailboxHash: $currentMailbox->mailbox_hash,
domainHash: $currentMailbox->domain_hash,
metadata: ['email_id' => $email->id, 'subject' => $email->subject],
userId: auth()->id(),
userType: auth()->check() ? 'authenticated' : 'guest',
ipAddress: request()->ip(),
userAgent: request()->userAgent()
);
}
}
}
public function switchMailbox($id)
{
$this->currentMailboxId = $id;
$this->selectedEmailId = null;
$this->search = '';
$this->resetPage();
Session::put('current_mailbox_id', $id);
// Track analytics for switching mailbox
$currentMailbox = $this->active_mailboxes->firstWhere('id', $id);
if ($currentMailbox) {
TrackAnalytics::dispatch(
eventType: 'mailbox_accessed',
mailboxHash: $currentMailbox->mailbox_hash,
domainHash: $currentMailbox->domain_hash,
userId: auth()->id(),
userType: auth()->check() ? 'authenticated' : 'guest',
ipAddress: request()->ip(),
userAgent: request()->userAgent()
);
$currentMailbox->update([
'last_accessed_at' => now(),
'last_accessed_ip' => request()->ip(),
]);
}
}
public function createMailbox()
{
$domainModel = Domain::where('name', $this->customDomain)
->where('is_active', true)
->accessibleBy(auth()->user())
->first();
if (! $domainModel) {
return;
}
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)),
'domain_hash' => $domainModel->domain_hash,
'user_id' => auth()->id(),
'session_id' => Session::getId(),
'address' => $address,
'type' => $this->createType === 'random' ? 'public' : 'custom',
'created_ip' => request()->ip(),
'last_accessed_ip' => request()->ip(),
'last_accessed_at' => now(),
'expires_at' => now()->addDays($this->getValidityDays()),
]);
$this->currentMailboxId = $mailbox->id;
$this->showCreateModal = false;
$this->customUsername = '';
Session::put('last_mailbox_id', $mailbox->id);
}
/**
* Auto-create a random mailbox on a public domain.
* Called when no mailboxes exist (first visit, or all deleted).
*/
public function autoCreateRandomMailbox(): void
{
$domain = Domain::query()
->where('is_active', true)
->where('is_archived', false)
->accessibleBy(auth()->user())
->whereJsonContains('allowed_types', 'public')
->inRandomOrder()
->first();
if (! $domain) {
return;
}
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)),
'domain_hash' => $domain->domain_hash,
'user_id' => auth()->id(),
'session_id' => Session::getId(),
'address' => $address,
'type' => 'public',
'created_ip' => request()->ip(),
'last_accessed_ip' => request()->ip(),
'last_accessed_at' => now(),
'expires_at' => now()->addDays($this->getValidityDays()),
]);
$this->currentMailboxId = $mailbox->id;
$this->isCreatingFirstMailbox = false;
Session::put('current_mailbox_id', $mailbox->id);
}
/**
* Get mailbox lifespan in days based on user tier.
* Guest: 1 day, Free: 3 days, Pro: 7 days, Enterprise: 14 days.
*/
protected function getValidityDays(): int
{
$user = auth()->user();
if (! $user) {
return 1; // Guests get 24 hours
}
return match (true) {
$user->isEnterprise() => 14,
$user->isAdmin() => 14, // Assuming admins get enterprise limits
$user->isPro() => 7,
$user->isFree() => 3,
default => 3,
};
}
public function finishAutoCreation(): void
{
if ($this->getActiveMailboxesProperty()->isEmpty()) {
$this->autoCreateRandomMailbox();
}
$this->isCreatingFirstMailbox = false;
// Ensure the component re-renders fully with the new data
$this->dispatch('$refresh');
}
public function deleteMailbox($id)
{
$mailbox = MailboxModel::find($id);
if ($mailbox) {
$mailbox->delete();
}
if ($this->currentMailboxId === $id) {
$this->currentMailboxId = $this->active_mailboxes->first()?->id;
$this->selectedEmailId = null;
session(['current_mailbox_id' => $this->currentMailboxId]);
}
// If all mailboxes deleted, auto-create a new random one
// Use a direct query or re-fetch the property to avoid cache issues
$hasActive = MailboxModel::query()
->where(function ($query) {
$query->where('session_id', Session::getId());
if (auth()->check()) {
$query->orWhere('user_id', auth()->id());
}
})
->where('is_blocked', false)
->exists();
if (! $hasActive) {
$this->isCreatingFirstMailbox = true;
}
}
public function downloadEmail($id)
{
// Mock download logic
$this->js("alert('Downloading email #{$id}... (Mock Action)')");
}
public function printEmail($id)
{
// Mock print logic
$this->js('window.print()');
}
public function deleteEmail($id)
{
// Mock delete logic
$this->js("alert('Email #{$id} deleted successfully! (Mock Action)')");
$this->selectedEmailId = null;
}
public function nextPage()
{
if ($this->page < $this->totalPages) {
$this->page++;
$this->selectedEmailId = null;
}
}
public function previousPage()
{
if ($this->page > 1) {
$this->page--;
$this->selectedEmailId = null;
}
}
public function generateQrCode($address)
{
// Mock QR generation with a slight delay
usleep(800000); // 800ms
$this->dispatch('qrCodeGenerated', address: $address);
}
public function getProcessedContent($email)
{
$body = EmailBody::where('unique_id_hash', $email->unique_id_hash)->first();
if (! $body) {
return 'Email body not found.';
}
$content = $body->body_html ?? $body->body_text;
$isText = $this->viewMode === 'text';
// Fallback to HTML if text is selected but body_text is empty
if ($isText && ! empty($body->body_text)) {
return trim(e($body->body_text));
}
if ($isText) {
// If fallback occurred, we sanitize the HTML to text
return trim(strip_tags($content));
}
if (! $this->allowRemoteContent) {
// Block remote assets by replacing src with data-src for img tags
return preg_replace('/<img\s[^>]*?\bsrc\s*=\s*([\'"])(.*?)\1/i', '<img $2 data-blocked-src=$1$2$1 src="data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 1 1\'%3E%3C/svg%3E" class="blocked-remote-asset shadow-sm border border-white/5 opacity-50"', $content);
}
return $content;
}
public function render()
{
$currentMailbox = $this->active_mailboxes->firstWhere('id', $this->currentMailboxId);
return view('livewire.mailbox', [
'emails' => $this->getEmailsProperty(),
'currentMailbox' => $currentMailbox,
'unreadCount' => $this->unread_count,
]);
}
}