feat(posta): implementazione client IMAP socket PHP, salvataggio locale .eml, due blade webmail stile Gmail (Ordinaria e PEC), anteprima allegati/protocollo in scheda unita e aggancio fornitore Nethome SAS

This commit is contained in:
michele 2026-09-06 20:55:57 +02:00
parent e0167b04ff
commit d7f8346952
15 changed files with 2764 additions and 90 deletions

View File

@ -0,0 +1,98 @@
<?php
namespace App\Console\Commands;
use App\Models\Stabile;
use App\Services\Posta\ImapMailboxService;
use Illuminate\Console\Command;
class FetchMailboxesCommand extends Command
{
protected $signature = 'gescon:fetch-mail
{--stabile= : Codice o ID stabile (es. 0013, 0021)}
{--tipo= : Filtra per tipo (email, pec, gmail)}
{--max=30 : Massimo numero di messaggi per casella}';
protected $description = 'Scarica la posta elettronica e PEC via IMAP/Gmail per gli stabili configurati e genera i file .EML e comunicazioni';
public function handle(ImapMailboxService $service): int
{
$this->info('======================================================================');
$this->info(' NETGESCON: SCARICO POSTA & PEC VIA PROTOCOLLO IMAP ');
$this->info('======================================================================');
$stabileFilter = $this->option('stabile');
$tipoFilter = strtolower((string) $this->option('tipo'));
$max = (int) $this->option('max') ?: 30;
$query = Stabile::where('attivo', true);
if ($stabileFilter) {
$query->where(function ($q) use ($stabileFilter) {
$q->where('codice_stabile', $stabileFilter)
->orWhere('cod_stabile', $stabileFilter)
->orWhere('id', $stabileFilter);
});
}
$stabili = $query->get();
if ($stabili->isEmpty()) {
$this->warn('Nessuno stabile attivo trovato con i filtri specificati.');
return Command::SUCCESS;
}
$totImported = 0;
$totSkipped = 0;
$totErrors = 0;
foreach ($stabili as $stabile) {
$config = (array) ($stabile->configurazione_avanzata ?? []);
$posta = (array) ($config['posta'] ?? []);
$caselle = array_values(array_filter((array) ($posta['caselle'] ?? []), 'is_array'));
if (empty($caselle)) {
continue;
}
$this->line("\nElaborazione Stabile [{$stabile->codice_stabile}] {$stabile->denominazione} (" . count($caselle) . " caselle)");
foreach ($caselle as $idx => $mailbox) {
if (empty($mailbox['enabled'])) {
continue;
}
$tipo = strtolower(trim((string) ($mailbox['tipo'] ?? 'imap')));
if ($tipoFilter && $tipo !== $tipoFilter) {
continue;
}
$label = $mailbox['label'] ?: ($mailbox['email'] ?: "Casella " . ($idx + 1));
$this->line(" -> Scansione casella [{$tipo}]: {$label} ({$mailbox['email']})");
if ($tipo === 'gmail') {
$this->comment(" [Gmail API]: in attesa di autorizzazione sviluppatore Google. Usare IMAP con password applicazione.");
}
if (! empty($mailbox['host']) && ! empty($mailbox['username'])) {
$res = $service->fetchMailbox($stabile, $mailbox, $max);
$this->info(" Importati: {$res['imported']} | Duplicati: {$res['skipped']} | Errori: {$res['errors']}");
$totImported += $res['imported'];
$totSkipped += $res['skipped'];
$totErrors += $res['errors'];
}
}
}
$this->newLine();
$this->info("Riepilogo finale scarico posta:");
$this->table(
['Metrica', 'Valore'],
[
['Messaggi importati a nuovo', $totImported],
['Messaggi già presenti (duplicati)', $totSkipped],
['Errori di sincronizzazione', $totErrors],
]
);
return Command::SUCCESS;
}
}

View File

@ -1369,6 +1369,68 @@ public function importOfficialGmail(?int $index = null): void
->send();
}
public function testOfficialImap(int $index): void
{
$mailboxes = $this->normalizeOfficialMailboxes($this->officialMailboxes);
if (! isset($mailboxes[$index])) {
Notification::make()->title('Casella non trovata')->danger()->send();
return;
}
$mailbox = $mailboxes[$index];
$res = app(\App\Services\Posta\ImapMailboxService::class)->testMailbox($mailbox);
if ($res['success']) {
Notification::make()
->title('Test Connessione Riuscito')
->body($res['message'])
->success()
->send();
} else {
Notification::make()
->title('Test Connessione Fallito')
->body($res['message'])
->danger()
->send();
}
}
public function importOfficialImap(int $index): void
{
if (! $this->stabile instanceof StabileModel) {
return;
}
$mailboxes = $this->normalizeOfficialMailboxes($this->officialMailboxes);
if (! isset($mailboxes[$index])) {
Notification::make()->title('Casella non trovata')->danger()->send();
return;
}
$mailbox = $mailboxes[$index];
$res = app(\App\Services\Posta\ImapMailboxService::class)->fetchMailbox(
$this->stabile,
$mailbox,
max(1, min((int) $this->gmailImportMaxMessages, 50))
);
if (($res['errors'] ?? 0) > 0 && empty($res['imported'])) {
Notification::make()
->title('Errore scarico IMAP')
->body($res['error_message'] ?? 'Impossibile scaricare la posta.')
->danger()
->send();
return;
}
$msg = "Importati a nuovo: {$res['imported']} messaggi (.EML) | Già presenti: {$res['skipped']}";
Notification::make()
->title('Sincronizzazione IMAP completata')
->body($msg)
->success()
->send();
}
public function getGoogleSettingsUrl(): string
{
return GoogleDashboard::getUrl(panel: 'admin-filament');

View File

@ -0,0 +1,357 @@
<?php
namespace App\Filament\Pages\Posta;
use App\Models\CommunicationMessage;
use App\Models\Stabile;
use App\Models\Ticket;
use App\Models\UnitaImmobiliare;
use App\Models\User;
use App\Services\Posta\ImapMailboxService;
use App\Support\StabileContext;
use BackedEnum;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use UnitEnum;
class PostaOrdinariaWebmail extends Page
{
protected static ?string $navigationLabel = 'Posta Ordinaria (Webmail)';
protected static ?string $title = 'Posta Ordinaria';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-envelope';
protected static UnitEnum|string|null $navigationGroup = 'Comunicazioni';
protected static ?int $navigationSort = 1;
protected static ?string $slug = 'comunicazioni/posta-ordinaria';
protected string $view = 'filament.pages.posta.webmail';
public string $tipoCasella = 'email'; // 'email' o 'pec'
public ?int $selectedStabileId = null;
public string $currentFolder = 'inbox'; // 'inbox', 'starred', 'sent', 'drafts', 'trash', 'attachments'
public string $searchQuery = '';
public ?int $selectedMessageId = null;
public bool $isComposing = false;
// Campi per la composizione
public string $composeTo = '';
public string $composeCc = '';
public string $composeSubject = '';
public string $composeBody = '';
public ?int $composeStabileId = null;
// Modal Anteprima Allegato
public bool $showAttachmentModal = false;
public ?array $activeAttachment = null;
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}
public function mount(): void
{
$user = Auth::user();
if ($user instanceof User) {
$reqStabile = request()->integer('stabile_id');
if ($reqStabile > 0) {
$this->selectedStabileId = $reqStabile;
} else {
$this->selectedStabileId = StabileContext::resolveActiveStabileId($user);
}
}
}
public function getStabiliOptionsProperty(): array
{
$user = Auth::user();
if (! $user instanceof User) {
return [];
}
return StabileContext::accessibleStabili($user)
->mapWithKeys(fn(Stabile $s) => [(int) $s->id => trim((string) ($s->codice_stabile . ' - ' . $s->denominazione))])
->all();
}
public function selectFolder(string $folder): void
{
$this->currentFolder = $folder;
$this->selectedMessageId = null;
}
public function selectMessage(int $messageId): void
{
$this->selectedMessageId = $messageId;
$msg = CommunicationMessage::find($messageId);
if ($msg && $msg->status === 'received') {
$msg->update(['status' => 'read']);
}
}
public function closeMessage(): void
{
$this->selectedMessageId = null;
}
public function toggleStar(int $messageId): void
{
$msg = CommunicationMessage::find($messageId);
if ($msg) {
$meta = is_array($msg->metadata) ? $msg->metadata : [];
$meta['is_starred'] = ! ($meta['is_starred'] ?? false);
$msg->metadata = $meta;
$msg->save();
}
}
public function deleteMessage(int $messageId): void
{
$msg = CommunicationMessage::find($messageId);
if ($msg) {
$msg->delete();
if ($this->selectedMessageId === $messageId) {
$this->selectedMessageId = null;
}
Notification::make()->title('Messaggio eliminato')->success()->send();
}
}
public function createTicketFromMessage(int $messageId): void
{
$msg = CommunicationMessage::find($messageId);
if (! $msg) {
return;
}
if ($msg->ticket_id) {
Notification::make()->title('Ticket già presente')->warning()->send();
return;
}
$userId = Auth::id() ?: 1;
$ticket = Ticket::create([
'stabile_id' => $msg->stabile_id,
'unita_immobiliare_id' => $msg->metadata['unita_immobiliare_id'] ?? null,
'aperto_da_user_id' => $userId,
'titolo' => Str::limit($msg->metadata['subject'] ?? 'Ticket da email', 180, '...'),
'descrizione' => Str::limit($msg->message_text, 1000),
'priorita' => 'Media',
'stato' => 'Aperto',
'data_apertura' => now(),
]);
$msg->update(['ticket_id' => $ticket->id]);
Notification::make()
->title('Ticket #' . $ticket->id . ' creato')
->body('Il ticket è stato associato al messaggio e allo stabile.')
->success()
->send();
}
public function syncNow(): void
{
$user = Auth::user();
if (! $user instanceof User) {
return;
}
$service = app(ImapMailboxService::class);
$stabili = $this->selectedStabileId
? Stabile::where('id', $this->selectedStabileId)->get()
: StabileContext::accessibleStabili($user);
$totImported = 0;
foreach ($stabili as $stabile) {
$config = (array) ($stabile->configurazione_avanzata ?? []);
$posta = (array) ($config['posta'] ?? []);
$caselle = array_values(array_filter((array) ($posta['caselle'] ?? []), 'is_array'));
foreach ($caselle as $mailbox) {
if (empty($mailbox['enabled'])) {
continue;
}
$mTipo = strtolower((string) ($mailbox['tipo'] ?? 'imap'));
if ($this->tipoCasella === 'pec' && $mTipo !== 'pec') {
continue;
}
if ($this->tipoCasella === 'email' && $mTipo === 'pec') {
continue;
}
if (! empty($mailbox['host']) && ! empty($mailbox['username'])) {
$res = $service->fetchMailbox($stabile, $mailbox, 20);
$totImported += (int) ($res['imported'] ?? 0);
}
}
}
Notification::make()
->title('Sincronizzazione completata')
->body($totImported > 0 ? "Importati {$totImported} nuovi messaggi (.EML)" : "Nessun nuovo messaggio trovato.")
->success()
->send();
}
public function openCompose(): void
{
$this->isComposing = true;
$this->composeTo = '';
$this->composeCc = '';
$this->composeSubject = '';
$this->composeBody = '';
$this->composeStabileId = $this->selectedStabileId;
}
public function closeCompose(): void
{
$this->isComposing = false;
}
public function sendMessage(): void
{
if (trim($this->composeTo) === '') {
Notification::make()->title('Inserisci almeno un destinatario')->warning()->send();
return;
}
$stabileId = $this->composeStabileId ?: $this->selectedStabileId;
if (! $stabileId) {
$user = Auth::user();
$stabileId = $user instanceof User ? StabileContext::resolveActiveStabileId($user) : null;
}
$comm = CommunicationMessage::create([
'channel' => $this->tipoCasella,
'direction' => 'outbound',
'stabile_id' => $stabileId,
'sender_name' => Auth::user()->name ?? 'Amministrazione Condominiale',
'message_text' => $this->composeBody,
'status' => 'sent',
'received_at' => now(),
'metadata' => [
'subject' => $this->composeSubject ?: '(Nessun oggetto)',
'to' => [['email' => $this->composeTo, 'name' => '']],
'cc' => $this->composeCc ? [['email' => $this->composeCc, 'name' => '']] : [],
'body_html' => nl2br(e($this->composeBody)),
'is_pec' => $this->tipoCasella === 'pec',
],
]);
$this->isComposing = false;
Notification::make()->title('Messaggio registrato e pronto per l\'invio')->success()->send();
}
public function previewAttachment(string $filename, string $path, string $contentType): void
{
$this->activeAttachment = [
'filename' => $filename,
'path' => $path,
'content_type' => $contentType,
'exists' => file_exists($path),
];
$this->showAttachmentModal = true;
}
public function closeAttachmentModal(): void
{
$this->showAttachmentModal = false;
$this->activeAttachment = null;
}
public function getMessagesProperty()
{
$user = Auth::user();
$allowedStabileIds = $user instanceof User
? StabileContext::accessibleStabili($user)->pluck('id')->toArray()
: [];
$query = CommunicationMessage::with(['stabile', 'ticket'])
->whereIn('stabile_id', $allowedStabileIds);
if ($this->tipoCasella === 'pec') {
$query->where('channel', 'pec');
} else {
$query->whereIn('channel', ['email', 'gmail']);
}
if ($this->selectedStabileId) {
$query->where('stabile_id', $this->selectedStabileId);
}
if ($this->currentFolder === 'sent') {
$query->where('direction', 'outbound');
} elseif ($this->currentFolder === 'starred') {
$query->where('metadata->is_starred', true);
} elseif ($this->currentFolder === 'attachments') {
$query->whereNotNull('attachments')->where('attachments', '!=', '[]');
} else {
$query->where('direction', 'inbound');
}
if (trim($this->searchQuery) !== '') {
$like = '%' . trim($this->searchQuery) . '%';
$query->where(function ($q) use ($like) {
$q->where('sender_name', 'like', $like)
->orWhere('message_text', 'like', $like)
->orWhere('metadata->subject', 'like', $like)
->orWhere('metadata->sender_email', 'like', $like);
});
}
return $query->orderByDesc('received_at')->paginate(25);
}
public function getSelectedMessageProperty(): ?CommunicationMessage
{
if (! $this->selectedMessageId) {
return null;
}
return CommunicationMessage::with(['stabile', 'ticket'])->find($this->selectedMessageId);
}
public function getFolderCountsProperty(): array
{
$user = Auth::user();
$allowedStabileIds = $user instanceof User
? StabileContext::accessibleStabili($user)->pluck('id')->toArray()
: [];
$base = CommunicationMessage::whereIn('stabile_id', $allowedStabileIds);
if ($this->tipoCasella === 'pec') {
$base->where('channel', 'pec');
} else {
$base->whereIn('channel', ['email', 'gmail']);
}
if ($this->selectedStabileId) {
$base->where('stabile_id', $this->selectedStabileId);
}
return [
'inbox' => (clone $base)->where('direction', 'inbound')->count(),
'unread' => (clone $base)->where('direction', 'inbound')->where('status', 'received')->count(),
'sent' => (clone $base)->where('direction', 'outbound')->count(),
'starred' => (clone $base)->where('metadata->is_starred', true)->count(),
'attachments' => (clone $base)->whereNotNull('attachments')->where('attachments', '!=', '[]')->count(),
];
}
}

View File

@ -0,0 +1,346 @@
<?php
namespace App\Filament\Pages\Posta;
use App\Models\CommunicationMessage;
use App\Models\Stabile;
use App\Models\Ticket;
use App\Models\User;
use App\Services\Posta\ImapMailboxService;
use App\Support\StabileContext;
use BackedEnum;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use UnitEnum;
/**
* Pagina Filament per la Webmail di Posta Elettronica Certificata (PEC).
* Consente la consultazione, archiviazione locale .EML, associazione stabile/unità,
* apertura ticket e gestione allegati in interfaccia stile Gmail.
*/
class PostaPecWebmail extends Page
{
protected static ?string $navigationLabel = 'Posta Certificata (PEC)';
protected static ?string $title = 'Posta PEC (Webmail)';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-shield-check';
protected static UnitEnum|string|null $navigationGroup = 'Comunicazioni';
protected static ?int $navigationSort = 2;
protected static ?string $slug = 'comunicazioni/posta-pec';
protected string $view = 'filament.pages.posta.webmail';
public string $tipoCasella = 'pec';
public ?int $selectedStabileId = null;
public string $currentFolder = 'inbox'; // 'inbox', 'starred', 'sent', 'drafts', 'trash', 'attachments'
public string $searchQuery = '';
public ?int $selectedMessageId = null;
public bool $isComposing = false;
// Campi per la composizione
public string $composeTo = '';
public string $composeCc = '';
public string $composeSubject = '';
public string $composeBody = '';
public ?int $composeStabileId = null;
// Modal Anteprima Allegato
public bool $showAttachmentModal = false;
public ?array $activeAttachment = null;
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}
public function mount(): void
{
$user = Auth::user();
if ($user instanceof User) {
$reqStabile = request()->integer('stabile_id');
if ($reqStabile > 0) {
$this->selectedStabileId = $reqStabile;
} else {
$this->selectedStabileId = StabileContext::resolveActiveStabileId($user);
}
}
}
public function getStabiliOptionsProperty(): array
{
$user = Auth::user();
if (! $user instanceof User) {
return [];
}
return StabileContext::accessibleStabili($user)
->mapWithKeys(fn(Stabile $s) => [(int) $s->id => trim((string) ($s->codice_stabile . ' - ' . $s->denominazione))])
->all();
}
public function selectFolder(string $folder): void
{
$this->currentFolder = $folder;
$this->selectedMessageId = null;
}
public function selectMessage(int $messageId): void
{
$this->selectedMessageId = $messageId;
$msg = CommunicationMessage::find($messageId);
if ($msg && $msg->status === 'received') {
$msg->update(['status' => 'read']);
}
}
public function closeMessage(): void
{
$this->selectedMessageId = null;
}
public function toggleStar(int $messageId): void
{
$msg = CommunicationMessage::find($messageId);
if ($msg) {
$meta = is_array($msg->metadata) ? $msg->metadata : [];
$meta['is_starred'] = ! ($meta['is_starred'] ?? false);
$msg->metadata = $meta;
$msg->save();
}
}
public function deleteMessage(int $messageId): void
{
$msg = CommunicationMessage::find($messageId);
if ($msg) {
$msg->delete();
if ($this->selectedMessageId === $messageId) {
$this->selectedMessageId = null;
}
Notification::make()->title('Messaggio PEC eliminato')->success()->send();
}
}
public function createTicketFromMessage(int $messageId): void
{
$msg = CommunicationMessage::find($messageId);
if (! $msg) {
return;
}
if ($msg->ticket_id) {
Notification::make()->title('Ticket già presente')->warning()->send();
return;
}
$userId = Auth::id() ?: 1;
$ticket = Ticket::create([
'stabile_id' => $msg->stabile_id,
'unita_immobiliare_id' => $msg->metadata['unita_immobiliare_id'] ?? null,
'aperto_da_user_id' => $userId,
'titolo' => Str::limit($msg->metadata['subject'] ?? 'Ticket da PEC', 180, '...'),
'descrizione' => Str::limit($msg->message_text, 1000),
'priorita' => 'Alta',
'stato' => 'Aperto',
'data_apertura' => now(),
]);
$msg->update(['ticket_id' => $ticket->id]);
Notification::make()
->title('Ticket PEC #' . $ticket->id . ' creato')
->body('Il ticket è stato associato al messaggio e allo stabile con priorità ALTA.')
->success()
->send();
}
public function syncNow(): void
{
$user = Auth::user();
if (! $user instanceof User) {
return;
}
$service = app(ImapMailboxService::class);
$stabili = $this->selectedStabileId
? Stabile::where('id', $this->selectedStabileId)->get()
: StabileContext::accessibleStabili($user);
$totImported = 0;
foreach ($stabili as $stabile) {
$config = (array) ($stabile->configurazione_avanzata ?? []);
$posta = (array) ($config['posta'] ?? []);
$caselle = array_values(array_filter((array) ($posta['caselle'] ?? []), 'is_array'));
foreach ($caselle as $mailbox) {
if (empty($mailbox['enabled'])) {
continue;
}
$mTipo = strtolower((string) ($mailbox['tipo'] ?? 'imap'));
if ($mTipo !== 'pec') {
continue;
}
if (! empty($mailbox['host']) && ! empty($mailbox['username'])) {
$res = $service->fetchMailbox($stabile, $mailbox, 20);
$totImported += (int) ($res['imported'] ?? 0);
}
}
}
Notification::make()
->title('Sincronizzazione PEC completata')
->body($totImported > 0 ? "Importati {$totImported} nuovi messaggi PEC (.EML)" : "Nessun nuovo messaggio PEC trovato.")
->success()
->send();
}
public function openCompose(): void
{
$this->isComposing = true;
$this->composeTo = '';
$this->composeCc = '';
$this->composeSubject = '';
$this->composeBody = '';
$this->composeStabileId = $this->selectedStabileId;
}
public function closeCompose(): void
{
$this->isComposing = false;
}
public function sendMessage(): void
{
if (trim($this->composeTo) === '') {
Notification::make()->title('Inserisci almeno un destinatario PEC')->warning()->send();
return;
}
$stabileId = $this->composeStabileId ?: $this->selectedStabileId;
if (! $stabileId) {
$user = Auth::user();
$stabileId = $user instanceof User ? StabileContext::resolveActiveStabileId($user) : null;
}
CommunicationMessage::create([
'channel' => 'pec',
'direction' => 'outbound',
'stabile_id' => $stabileId,
'sender_name' => Auth::user()->name ?? 'Amministrazione Condominiale (PEC)',
'message_text' => $this->composeBody,
'status' => 'sent',
'received_at' => now(),
'metadata' => [
'subject' => $this->composeSubject ?: '(Nessun oggetto)',
'to' => [['email' => $this->composeTo, 'name' => '']],
'cc' => $this->composeCc ? [['email' => $this->composeCc, 'name' => '']] : [],
'body_html' => nl2br(e($this->composeBody)),
'is_pec' => true,
],
]);
$this->isComposing = false;
Notification::make()->title('Messaggio PEC registrato e pronto per l\'inoltro certificato')->success()->send();
}
public function previewAttachment(string $filename, string $path, string $contentType): void
{
$this->activeAttachment = [
'filename' => $filename,
'path' => $path,
'content_type' => $contentType,
'exists' => file_exists($path),
];
$this->showAttachmentModal = true;
}
public function closeAttachmentModal(): void
{
$this->showAttachmentModal = false;
$this->activeAttachment = null;
}
public function getMessagesProperty()
{
$user = Auth::user();
$allowedStabileIds = $user instanceof User
? StabileContext::accessibleStabili($user)->pluck('id')->toArray()
: [];
$query = CommunicationMessage::with(['stabile', 'ticket'])
->whereIn('stabile_id', $allowedStabileIds)
->where('channel', 'pec');
if ($this->selectedStabileId) {
$query->where('stabile_id', $this->selectedStabileId);
}
if ($this->currentFolder === 'sent') {
$query->where('direction', 'outbound');
} elseif ($this->currentFolder === 'starred') {
$query->where('metadata->is_starred', true);
} elseif ($this->currentFolder === 'attachments') {
$query->whereNotNull('attachments')->where('attachments', '!=', '[]');
} else {
$query->where('direction', 'inbound');
}
if (trim($this->searchQuery) !== '') {
$like = '%' . trim($this->searchQuery) . '%';
$query->where(function ($q) use ($like) {
$q->where('sender_name', 'like', $like)
->orWhere('message_text', 'like', $like)
->orWhere('metadata->subject', 'like', $like)
->orWhere('metadata->sender_email', 'like', $like);
});
}
return $query->orderByDesc('received_at')->paginate(25);
}
public function getSelectedMessageProperty(): ?CommunicationMessage
{
if (! $this->selectedMessageId) {
return null;
}
return CommunicationMessage::with(['stabile', 'ticket'])->find($this->selectedMessageId);
}
public function getFolderCountsProperty(): array
{
$user = Auth::user();
$allowedStabileIds = $user instanceof User
? StabileContext::accessibleStabili($user)->pluck('id')->toArray()
: [];
$base = CommunicationMessage::whereIn('stabile_id', $allowedStabileIds)->where('channel', 'pec');
if ($this->selectedStabileId) {
$base->where('stabile_id', $this->selectedStabileId);
}
return [
'inbox' => (clone $base)->where('direction', 'inbound')->count(),
'unread' => (clone $base)->where('direction', 'inbound')->where('status', 'received')->count(),
'sent' => (clone $base)->where('direction', 'outbound')->count(),
'starred' => (clone $base)->where('metadata->is_starred', true)->count(),
'attachments' => (clone $base)->whereNotNull('attachments')->where('attachments', '!=', '[]')->count(),
];
}
}

View File

@ -161,6 +161,10 @@ public static function canAccess(): bool
public string $recapitiViewMode = 'diviso';
/** Modal Anteprima Documenti e Allegati Comunicazioni */
public bool $showDocModal = false;
public ?array $activeDoc = null;
/**
* @var array<int, string>
*/
@ -1154,6 +1158,25 @@ public function getEstrattoContoNominativoDettaglioProperty(): array
];
}
public function previewProtocolDoc(string $id, string $title, ?string $filePath = null, ?string $fileContent = null, ?string $contentType = null): void
{
$this->activeDoc = [
'id' => $id,
'title' => $title,
'path' => $filePath,
'content' => $fileContent,
'content_type' => $contentType ?: 'application/pdf',
'exists' => $filePath && file_exists($filePath),
];
$this->showDocModal = true;
}
public function closeDocModal(): void
{
$this->showDocModal = false;
$this->activeDoc = null;
}
public function getProtocolloComunicazioniProperty(): array
{
if (! $this->unita || ! $this->unita->stabile) {
@ -1166,7 +1189,7 @@ public function getProtocolloComunicazioniProperty(): array
$results = [];
// 1. Dati da protoc_ec
// 1. Dati da protoc_ec (Estratti conto storici)
if (DbSchema::connection('gescon_import')->hasTable('protoc_ec')) {
$ecQuery = DB::connection('gescon_import')->table('protoc_ec')
->where('cod_stabile', $codStabile)
@ -1180,7 +1203,18 @@ public function getProtocolloComunicazioniProperty(): array
foreach ($ecQuery as $r) {
$dtRaw = $r->data_invio ?? null;
$dtFmt = $dtRaw ? date('d/m/Y', strtotime((string) $dtRaw)) : '—';
$ts = 0;
$dtFmt = '—';
if ($dtRaw) {
try {
$c = Carbon::parse((string) $dtRaw);
$ts = $c->timestamp;
$dtFmt = $c->format('d/m/Y');
} catch (\Throwable $e) {
$dtFmt = (string) $dtRaw;
}
}
$pdfName = trim((string) ($r->nome_pdf ?? ''));
$pdfPath = $pdfName !== '' ? "/mnt/gescon-archives/gescon/{$codStabile}/E_C/{$pdfName}" : null;
$pdfExists = $pdfPath && file_exists($pdfPath);
@ -1199,7 +1233,7 @@ public function getProtocolloComunicazioniProperty(): array
'file_path' => $pdfPath,
'file_exists' => $pdfExists,
'note' => $r->note,
'created_at_sort' => $dtRaw ?: $r->created_at,
'timestamp_sort' => $ts,
];
}
}
@ -1220,7 +1254,18 @@ public function getProtocolloComunicazioniProperty(): array
foreach ($corrQuery as $r) {
$dtRaw = $r->data_invio ?? null;
$dtFmt = $dtRaw ? date('d/m/Y', strtotime((string) $dtRaw)) : '—';
$ts = 0;
$dtFmt = '—';
if ($dtRaw) {
try {
$c = Carbon::parse((string) $dtRaw);
$ts = $c->timestamp;
$dtFmt = $c->format('d/m/Y');
} catch (\Throwable $e) {
$dtFmt = (string) $dtRaw;
}
}
$docName = trim((string) ($r->lettera_tipo_caricata ?? ''));
$docPath = $docName !== '' ? "/mnt/gescon-archives/gescon/{$codStabile}/{$docName}" : null;
$docExists = $docPath && file_exists($docPath);
@ -1239,13 +1284,64 @@ public function getProtocolloComunicazioniProperty(): array
'file_path' => $docPath,
'file_exists' => $docExists,
'note' => $r->note,
'created_at_sort' => $dtRaw ?: $r->created_at,
'timestamp_sort' => $ts,
];
}
}
// 3. Dati da CommunicationMessage (Posta IMAP, PEC, EML)
if (DbSchema::hasTable('communication_messages')) {
$unitEmails = [];
$recapiti = $this->getRecapitiMulticanaleTableProperty();
foreach ($recapiti as $rc) {
if (in_array($rc['canale'] ?? '', ['Email', 'Email PEC'], true) && ! empty($rc['valore'])) {
$unitEmails[] = mb_strtolower(trim((string) $rc['valore']));
}
}
$unitEmails = array_values(array_unique($unitEmails));
$commQuery = \App\Models\CommunicationMessage::query()
->where('stabile_id', $this->unita->stabile_id)
->where(function ($q) use ($unitEmails) {
$q->where('metadata->unita_immobiliare_id', $this->unita->id);
if (! empty($unitEmails)) {
$q->orWhereIn('metadata->sender_email', $unitEmails)
->orWhereIn('metadata->recipient_email', $unitEmails);
}
})
->orderByDesc('received_at')
->get();
foreach ($commQuery as $cm) {
$meta = is_array($cm->metadata) ? $cm->metadata : [];
$dt = $cm->received_at ? Carbon::parse($cm->received_at) : null;
$dtFmt = $dt ? $dt->format('d/m/Y H:i') : '—';
$ts = $dt ? $dt->timestamp : 0;
$attachments = is_array($cm->attachments) ? $cm->attachments : [];
$firstAtt = $attachments[0] ?? null;
$results[] = [
'id' => 'comm_' . $cm->id,
'protocollo' => 'MSG #' . $cm->id,
'tipo' => $cm->direction === 'inbound' ? 'Ricevuta (Inbound)' : 'Inviata (Outbound)',
'canale' => strtoupper($cm->channel),
'data_invio' => $dtFmt,
'destinatario' => $cm->sender_name ?: ($meta['sender_email'] ?? '—'),
'ruolo' => $cm->channel === 'pec' ? 'PEC' : 'Email',
'oggetto' => $meta['subject'] ?? \Illuminate\Support\Str::limit($cm->message_text, 60),
'importo' => null,
'nome_file' => $firstAtt['filename'] ?? (! empty($meta['eml_path']) ? basename($meta['eml_path']) : null),
'file_path' => $firstAtt['path'] ?? ($meta['eml_path'] ?? null),
'file_exists' => ($firstAtt['path'] ?? null) ? file_exists($firstAtt['path']) : (! empty($meta['eml_path']) && file_exists($meta['eml_path'])),
'note' => $cm->message_text ? \Illuminate\Support\Str::limit($cm->message_text, 120) : null,
'timestamp_sort' => $ts,
];
}
}
// Ordinamento cronologico decrescente rigoroso basato su timestamp
usort($results, function ($a, $b) {
return strcmp((string) ($b['created_at_sort'] ?? ''), (string) ($a['created_at_sort'] ?? ''));
return ($b['timestamp_sort'] ?? 0) <=> ($a['timestamp_sort'] ?? 0);
});
return $results;

View File

@ -60,6 +60,14 @@ public function unitaImmobiliare(): BelongsTo
return $this->belongsTo(UnitaImmobiliare::class, 'unita_id');
}
/**
* Alias per relazione con unità immobiliare
*/
public function unita(): BelongsTo
{
return $this->belongsTo(UnitaImmobiliare::class, 'unita_id');
}
/**
* Tipo di relazione configurabile
*/

View File

@ -0,0 +1,273 @@
<?php
namespace App\Services\Posta;
use Carbon\Carbon;
class EmlParser
{
/**
* @return array{
* message_id: ?string,
* date: ?Carbon,
* from: array{name: string, email: string},
* to: array<int, array{name: string, email: string}>,
* cc: array<int, array{name: string, email: string}>,
* subject: string,
* body_text: string,
* body_html: ?string,
* is_pec: bool,
* attachments: array<int, array{
* filename: string,
* content_type: string,
* size: int,
* content: string,
* is_inline: bool
* }>
* }
*/
public function parse(string $rawEml): array
{
$rawEml = str_replace(["\r\n", "\r"], "\n", $rawEml);
$headerBodySplit = explode("\n\n", $rawEml, 2);
$rawHeaders = $headerBodySplit[0] ?? '';
$rawBody = $headerBodySplit[1] ?? '';
$headers = $this->parseHeaders($rawHeaders);
$fromStr = $headers['from'] ?? '';
$from = $this->parseAddress($fromStr);
$toStr = $headers['to'] ?? '';
$to = $this->parseAddressList($toStr);
$ccStr = $headers['cc'] ?? '';
$cc = $this->parseAddressList($ccStr);
$subject = $this->decodeMimeHeader($headers['subject'] ?? '(Nessun oggetto)');
$dateStr = $headers['date'] ?? null;
$date = null;
if ($dateStr) {
try {
$date = Carbon::parse($dateStr);
} catch (\Throwable) {
$date = now();
}
} else {
$date = now();
}
$messageId = trim($headers['message-id'] ?? '');
$messageId = trim($messageId, '<>');
$contentType = $headers['content-type'] ?? 'text/plain';
$contentTransferEncoding = $headers['content-transfer-encoding'] ?? '7bit';
$isPec = false;
if (
str_contains(strtolower($headers['x-trasporto'] ?? ''), 'pec') ||
str_contains(strtolower($headers['x-ricevuta'] ?? ''), 'accettazione') ||
str_contains(strtolower($headers['x-ricevuta'] ?? ''), 'avvenuta-consegna') ||
str_contains(strtolower($headers['x-tipo-ricevuta'] ?? ''), 'pec') ||
str_contains(strtolower($fromStr), 'pec') ||
str_contains(strtolower($fromStr), 'legalmail') ||
str_contains(strtolower($fromStr), 'postecert') ||
str_contains(strtolower($fromStr), 'arubapec')
) {
$isPec = true;
}
$parsedParts = $this->parseBodyParts($rawBody, $contentType, $contentTransferEncoding);
return [
'message_id' => $messageId ?: null,
'date' => $date,
'from' => $from,
'to' => $to,
'cc' => $cc,
'subject' => $subject,
'body_text' => $parsedParts['text'],
'body_html' => $parsedParts['html'],
'is_pec' => $isPec,
'attachments' => $parsedParts['attachments'],
];
}
private function parseHeaders(string $rawHeaders): array
{
$headers = [];
$lines = explode("\n", $rawHeaders);
$currentHeader = null;
foreach ($lines as $line) {
if ($line === '') {
continue;
}
if (preg_match('/^[ \t]+/', $line)) {
if ($currentHeader !== null) {
$headers[$currentHeader] .= ' ' . trim($line);
}
} elseif (preg_match('/^([^:]+):(.*)$/', $line, $matches)) {
$currentHeader = strtolower(trim($matches[1]));
$headers[$currentHeader] = trim($matches[2]);
}
}
return $headers;
}
private function parseAddress(string $addr): array
{
$addr = trim($addr);
if (preg_match('/^(.*?)\s*<([^>]+)>$/', $addr, $m)) {
return [
'name' => $this->decodeMimeHeader(trim($m[1], " \t\n\r\0\x0B\"'")),
'email' => trim($m[2]),
];
}
return [
'name' => '',
'email' => trim($addr, " \t\n\r\0\x0B\"'<>"),
];
}
private function parseAddressList(string $addrList): array
{
if (trim($addrList) === '') {
return [];
}
$items = explode(',', $addrList);
$result = [];
foreach ($items as $item) {
$parsed = $this->parseAddress($item);
if ($parsed['email'] !== '') {
$result[] = $parsed;
}
}
return $result;
}
private function decodeMimeHeader(string $value): string
{
if (function_exists('mb_decode_mimeheader')) {
return mb_decode_mimeheader($value);
}
if (function_exists('iconv_mime_decode')) {
return iconv_mime_decode($value, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, 'UTF-8');
}
return $value;
}
private function parseBodyParts(string $body, string $contentType, string $transferEncoding): array
{
$result = [
'text' => '',
'html' => null,
'attachments' => [],
];
if (preg_match('/boundary=["\']?([^"\';]+)["\']?/i', $contentType, $matches)) {
$boundary = $matches[1];
$parts = explode('--' . $boundary, $body);
foreach ($parts as $part) {
$part = trim($part);
if ($part === '' || $part === '--') {
continue;
}
$subSplit = explode("\n\n", $part, 2);
$subHeadersRaw = $subSplit[0] ?? '';
$subBodyRaw = $subSplit[1] ?? '';
$subHeaders = $this->parseHeaders($subHeadersRaw);
$subContentType = $subHeaders['content-type'] ?? 'text/plain';
$subTransferEncoding = $subHeaders['content-transfer-encoding'] ?? '7bit';
$subContentDisposition = $subHeaders['content-disposition'] ?? '';
$filename = null;
if (preg_match('/filename=["\']?([^"\';]+)["\']?/i', $subContentDisposition . ';' . $subContentType, $fnMatches)) {
$filename = $this->decodeMimeHeader($fnMatches[1]);
} elseif (preg_match('/name=["\']?([^"\';]+)["\']?/i', $subContentType, $fnMatches)) {
$filename = $this->decodeMimeHeader($fnMatches[1]);
}
if (str_contains(strtolower($subContentType), 'multipart/')) {
$nested = $this->parseBodyParts($subBodyRaw, $subContentType, $subTransferEncoding);
if ($nested['text'] !== '' && $result['text'] === '') {
$result['text'] = $nested['text'];
}
if ($nested['html'] !== null && $result['html'] === null) {
$result['html'] = $nested['html'];
}
foreach ($nested['attachments'] as $att) {
$result['attachments'][] = $att;
}
continue;
}
$decodedBody = $this->decodeContent($subBodyRaw, $subTransferEncoding);
$isAttachment = (bool) $filename || str_contains(strtolower($subContentDisposition), 'attachment');
if ($isAttachment) {
$fn = $filename ?: ('allegato_' . (count($result['attachments']) + 1));
$cleanType = trim(explode(';', $subContentType)[0]);
$result['attachments'][] = [
'filename' => $fn,
'content_type' => $cleanType ?: 'application/octet-stream',
'size' => strlen($decodedBody),
'content' => $decodedBody,
'is_inline' => str_contains(strtolower($subContentDisposition), 'inline'),
];
} elseif (str_contains(strtolower($subContentType), 'text/html')) {
$result['html'] = $decodedBody;
if ($result['text'] === '') {
$result['text'] = trim(strip_tags($decodedBody));
}
} elseif (str_contains(strtolower($subContentType), 'text/plain')) {
$result['text'] = $decodedBody;
} else {
$fn = $filename ?: ('file_' . (count($result['attachments']) + 1));
$cleanType = trim(explode(';', $subContentType)[0]);
$result['attachments'][] = [
'filename' => $fn,
'content_type' => $cleanType ?: 'application/octet-stream',
'size' => strlen($decodedBody),
'content' => $decodedBody,
'is_inline' => false,
];
}
}
} else {
$decodedBody = $this->decodeContent($body, $transferEncoding);
if (str_contains(strtolower($contentType), 'text/html')) {
$result['html'] = $decodedBody;
$result['text'] = trim(strip_tags($decodedBody));
} else {
$result['text'] = $decodedBody;
}
}
return $result;
}
private function decodeContent(string $data, string $encoding): string
{
$encoding = strtolower(trim($encoding));
if ($encoding === 'base64') {
return (string) base64_decode($data);
}
if ($encoding === 'quoted-printable') {
return (string) quoted_printable_decode($data);
}
return $data;
}
}

View File

@ -0,0 +1,211 @@
<?php
namespace App\Services\Posta;
use Exception;
class ImapClient
{
private mixed $socket = null;
private int $tagIndex = 0;
public function testConnection(string $host, int $port, string $username, string $password, string $encryption = 'ssl'): array
{
try {
$this->connect($host, $port, $username, $password, $encryption);
$this->disconnect();
return ['success' => true, 'message' => 'Connessione e autenticazione IMAP riuscite con successo.'];
} catch (\Throwable $e) {
return ['success' => false, 'message' => 'Errore IMAP: ' . $e->getMessage()];
}
}
public function connect(string $host, int $port, string $username, string $password, string $encryption = 'ssl'): void
{
$prefix = strtolower($encryption) === 'ssl' ? 'ssl://' : '';
$target = $prefix . $host;
$timeout = 15;
$context = stream_context_create([
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true,
],
]);
$this->socket = @stream_socket_client(
$target . ':' . $port,
$errno,
$errstr,
$timeout,
STREAM_CLIENT_CONNECT,
$context
);
if (! $this->socket) {
throw new Exception("Impossibile connettersi al server IMAP {$host}:{$port} ({$errstr})");
}
stream_set_timeout($this->socket, $timeout);
// Leggi il banner iniziale
$greeting = $this->readLine();
if (! str_starts_with($greeting, '* OK')) {
throw new Exception("Risposta iniziale inattesa dal server IMAP: {$greeting}");
}
// Login
$userEscaped = addcslashes($username, '\\"');
$passEscaped = addcslashes($password, '\\"');
$res = $this->sendCommand("LOGIN \"{$userEscaped}\" \"{$passEscaped}\"");
if (! $res['ok']) {
throw new Exception("Autenticazione IMAP fallita per l'utente {$username}: " . ($res['response'] ?? ''));
}
}
public function selectFolder(string $folder = 'INBOX'): array
{
$folderEscaped = addcslashes($folder, '\\"');
$res = $this->sendCommand("SELECT \"{$folderEscaped}\"");
if (! $res['ok']) {
throw new Exception("Impossibile aprire la cartella IMAP '{$folder}'");
}
$exists = 0;
foreach ($res['lines'] as $line) {
if (preg_match('/^\*\s+(\d+)\s+EXISTS/i', $line, $m)) {
$exists = (int) $m[1];
}
}
return ['ok' => true, 'exists' => $exists];
}
/**
* @return array<int, int>
*/
public function search(string $criteria = 'ALL'): array
{
$res = $this->sendCommand("SEARCH {$criteria}");
if (! $res['ok']) {
return [];
}
$ids = [];
foreach ($res['lines'] as $line) {
if (str_starts_with($line, '* SEARCH')) {
$parts = explode(' ', trim(substr($line, 8)));
foreach ($parts as $p) {
if (is_numeric($p) && (int) $p > 0) {
$ids[] = (int) $p;
}
}
}
}
return $ids;
}
public function fetchRawEml(int $msgId): string
{
$tag = $this->nextTag();
$cmd = "{$tag} FETCH {$msgId} (BODY.PEEK[])\r\n";
fwrite($this->socket, $cmd);
$firstLine = $this->readLine();
$expectedLength = null;
if (preg_match('/\{(\d+)\}$/', trim($firstLine), $m)) {
$expectedLength = (int) $m[1];
}
$rawEml = '';
if ($expectedLength !== null && $expectedLength > 0) {
$bytesRead = 0;
while ($bytesRead < $expectedLength && ! feof($this->socket)) {
$chunk = fread($this->socket, min(8192, $expectedLength - $bytesRead));
if ($chunk === false || $chunk === '') {
break;
}
$rawEml .= $chunk;
$bytesRead += strlen($chunk);
}
}
// Leggi fino al tag di completamento
while (! feof($this->socket)) {
$line = $this->readLine();
if (str_starts_with($line, $tag . ' OK')) {
break;
}
if (str_starts_with($line, $tag . ' NO') || str_starts_with($line, $tag . ' BAD')) {
break;
}
}
return $rawEml;
}
public function disconnect(): void
{
if ($this->socket) {
try {
$this->sendCommand('LOGOUT');
} catch (\Throwable) {
// ignore
}
@fclose($this->socket);
$this->socket = null;
}
}
private function nextTag(): string
{
$this->tagIndex++;
return 'A' . str_pad((string) $this->tagIndex, 4, '0', STR_PAD_LEFT);
}
private function sendCommand(string $command): array
{
if (! $this->socket) {
throw new Exception("Socket IMAP non connesso.");
}
$tag = $this->nextTag();
$payload = "{$tag} {$command}\r\n";
fwrite($this->socket, $payload);
$lines = [];
$ok = false;
$response = '';
while (! feof($this->socket)) {
$line = $this->readLine();
$lines[] = $line;
if (str_starts_with($line, $tag . ' OK')) {
$ok = true;
$response = $line;
break;
}
if (str_starts_with($line, $tag . ' NO') || str_starts_with($line, $tag . ' BAD')) {
$ok = false;
$response = $line;
break;
}
}
return [
'ok' => $ok,
'response' => $response,
'lines' => $lines,
];
}
private function readLine(): string
{
$line = fgets($this->socket);
return $line !== false ? trim($line, "\r\n") : '';
}
}

View File

@ -0,0 +1,298 @@
<?php
namespace App\Services\Posta;
use App\Models\CommunicationMessage;
use App\Models\Persona;
use App\Models\PersonaUnitaRelazione;
use App\Models\Stabile;
use App\Models\Ticket;
use App\Models\UnitaImmobiliare;
use App\Services\TenantArchivePathService;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class ImapMailboxService
{
public function __construct(
private readonly ImapClient $imapClient,
private readonly EmlParser $emlParser,
private readonly TenantArchivePathService $pathService
) {}
public function testMailbox(array $config): array
{
$host = trim((string) ($config['host'] ?? ''));
$port = (int) ($config['port'] ?? 993);
$username = trim((string) ($config['username'] ?? ($config['email'] ?? '')));
$password = (string) ($config['password'] ?? '');
$encryption = trim((string) ($config['encryption'] ?? 'ssl')) ?: 'ssl';
if ($host === '' || $username === '') {
return ['success' => false, 'message' => 'Host e Username sono obbligatori per testare la connessione IMAP.'];
}
return $this->imapClient->testConnection($host, $port, $username, $password, $encryption);
}
/**
* Scarica e memorizza le email/PEC via IMAP in formato .EML e popola CommunicationMessage e Ticket.
*
* @param Stabile $stabile
* @param array $mailbox
* @param int $maxMessages
* @return array{imported: int, skipped: int, errors: int, messages: array}
*/
public function fetchMailbox(Stabile $stabile, array $mailbox, int $maxMessages = 50): array
{
$host = trim((string) ($mailbox['host'] ?? ''));
$port = (int) ($mailbox['port'] ?? 993);
$username = trim((string) ($mailbox['username'] ?? ($mailbox['email'] ?? '')));
$password = (string) ($mailbox['password'] ?? '');
$encryption = trim((string) ($mailbox['encryption'] ?? 'ssl')) ?: 'ssl';
$folder = trim((string) ($mailbox['folder'] ?? 'INBOX')) ?: 'INBOX';
$isPec = strtolower(trim((string) ($mailbox['tipo'] ?? ''))) === 'pec';
$createTicket = (bool) ($mailbox['crea_ticket_automatico'] ?? true);
if ($host === '' || $username === '') {
return [
'imported' => 0,
'skipped' => 0,
'errors' => 1,
'message' => 'Parametri IMAP incompleti (Host / Username mancanti)',
'messages' => [],
];
}
$stats = ['imported' => 0, 'skipped' => 0, 'errors' => 0, 'messages' => []];
try {
$this->imapClient->connect($host, $port, $username, $password, $encryption);
$this->imapClient->selectFolder($folder);
$msgIds = $this->imapClient->search('ALL');
// Prendi i messaggi più recenti fino a $maxMessages
$recentIds = array_slice(array_reverse($msgIds), 0, $maxMessages);
foreach ($recentIds as $msgId) {
try {
$rawEml = $this->imapClient->fetchRawEml($msgId);
if (trim($rawEml) === '') {
continue;
}
$res = $this->ingestEmlString($rawEml, $stabile, $mailbox, $isPec, $createTicket);
if ($res['status'] === 'imported') {
$stats['imported']++;
$stats['messages'][] = $res;
} elseif ($res['status'] === 'duplicate') {
$stats['skipped']++;
} else {
$stats['errors']++;
}
} catch (\Throwable $e) {
$stats['errors']++;
}
}
$this->imapClient->disconnect();
} catch (\Throwable $e) {
$stats['errors']++;
$stats['error_message'] = $e->getMessage();
}
return $stats;
}
/**
* Ingesta un messaggio da stringa EML grezza
*/
public function ingestEmlString(
string $rawEml,
Stabile $stabile,
array $mailboxConfig = [],
bool $forcePec = false,
bool $createTicket = true
): array {
$parsed = $this->emlParser->parse($rawEml);
$messageId = $parsed['message_id'] ?: ('local_' . md5($rawEml));
// Verifica duplicati su CommunicationMessage
$existing = CommunicationMessage::where('stabile_id', $stabile->id)
->where(function ($q) use ($messageId) {
$q->where('external_message_id', $messageId)
->orWhere('metadata->message_id', $messageId);
})
->first();
if ($existing) {
return ['status' => 'duplicate', 'id' => $existing->id];
}
$channel = ($forcePec || $parsed['is_pec']) ? 'pec' : 'email';
$year = $parsed['date'] ? $parsed['date']->format('Y') : date('Y');
// Cartelle dedicate di archiviazione .EML
$postaSubdir = $channel === 'pec' ? 'posta_pec' : 'posta_ordinaria';
$storageDir = $this->pathService->stabileAbsolutePath($stabile, "{$postaSubdir}/{$year}");
if (! is_dir($storageDir)) {
@mkdir($storageDir, 0755, true);
}
$safeMsgId = preg_replace('/[^a-zA-Z0-9_-]/', '_', $messageId);
$emlFilename = "{$safeMsgId}.eml";
$emlFullPath = "{$storageDir}/{$emlFilename}";
@file_put_contents($emlFullPath, $rawEml);
// Salva gli allegati estratti
$savedAttachments = [];
if (! empty($parsed['attachments'])) {
$attachmentsDir = "{$storageDir}/allegati/{$safeMsgId}";
if (! is_dir($attachmentsDir)) {
@mkdir($attachmentsDir, 0755, true);
}
foreach ($parsed['attachments'] as $idx => $att) {
$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', (string) $att['filename']);
if ($safeName === '') {
$safeName = "allegato_{$idx}.dat";
}
$attPath = "{$attachmentsDir}/{$safeName}";
@file_put_contents($attPath, $att['content']);
$savedAttachments[] = [
'filename' => $att['filename'],
'path' => $attPath,
'content_type' => $att['content_type'],
'size' => $att['size'],
'is_inline' => $att['is_inline'],
];
}
}
// Risoluzione Unità Immobiliare associata al mittente
$matchedUnita = $this->matchUnitaBySender($stabile, $parsed['from']['email']);
// Creazione Ticket opzionale
$ticketId = null;
if ($createTicket) {
try {
$userId = \Illuminate\Support\Facades\Auth::id();
if (! $userId && $stabile->amministratore_id) {
$userId = DB::table('amministratori')->where('id', $stabile->amministratore_id)->value('user_id');
}
if (! $userId) {
$userId = \App\Models\User::query()->value('id') ?: 1;
}
$ticket = Ticket::create([
'stabile_id' => $stabile->id,
'unita_immobiliare_id' => $matchedUnita?->id,
'aperto_da_user_id' => (int) $userId,
'titolo' => Str::limit($parsed['subject'], 180, '...'),
'descrizione' => Str::limit($parsed['body_text'] ?: strip_tags((string) $parsed['body_html']), 1000),
'priorita' => $channel === 'pec' ? 'Alta' : 'Media',
'stato' => 'Aperto',
'data_apertura' => now(),
]);
$ticketId = $ticket->id;
} catch (\Throwable $e) {
Log::warning('ImapMailboxService: Ticket creation failed: ' . $e->getMessage());
}
}
// Creazione CommunicationMessage
$senderDisplay = $parsed['from']['name'] ? "{$parsed['from']['name']} <{$parsed['from']['email']}>" : $parsed['from']['email'];
$comm = CommunicationMessage::create([
'channel' => $channel,
'direction' => 'inbound',
'external_message_id' => $messageId,
'stabile_id' => $stabile->id,
'sender_name' => $senderDisplay,
'phone_number' => null,
'message_text' => $parsed['body_text'] ?: strip_tags((string) $parsed['body_html']),
'attachments' => $savedAttachments,
'ticket_id' => $ticketId,
'status' => 'received',
'received_at' => $parsed['date'] ?: now(),
'metadata' => [
'subject' => $parsed['subject'],
'sender_email' => $parsed['from']['email'],
'sender_name' => $parsed['from']['name'],
'to' => $parsed['to'],
'cc' => $parsed['cc'],
'body_html' => $parsed['body_html'],
'eml_path' => $emlFullPath,
'is_pec' => $channel === 'pec',
'unita_immobiliare_id' => $matchedUnita?->id,
'codice_unita' => $matchedUnita?->codice_unita,
'mailbox_label' => $mailboxConfig['label'] ?? '',
'mailbox_email' => $mailboxConfig['email'] ?? '',
],
]);
return [
'status' => 'imported',
'id' => $comm->id,
'channel' => $channel,
'subject' => $parsed['subject'],
'from' => $senderDisplay,
'date' => $parsed['date']?->format('d/m/Y H:i'),
'ticket_id' => $ticketId,
'unita_id' => $matchedUnita?->id,
'has_attachments' => count($savedAttachments) > 0,
];
}
private function matchUnitaBySender(Stabile $stabile, string $senderEmail): ?UnitaImmobiliare
{
$senderEmail = strtolower(trim($senderEmail));
if ($senderEmail === '') {
return null;
}
// Cerca persone associate all'email o contatti
$personaIds = Persona::where('email_principale', $senderEmail)
->orWhere('email_secondaria', $senderEmail)
->orWhere('pec', $senderEmail)
->pluck('id')
->toArray();
if (empty($personaIds)) {
// Cerca in rubrica o recapiti
if (DB::getSchemaBuilder()->hasTable('rubrica_universale')) {
$rubricaIds = DB::table('rubrica_universale')
->where(function ($q) use ($senderEmail) {
$q->where('email', $senderEmail)
->orWhere('email_secondaria', $senderEmail)
->orWhere('pec', $senderEmail);
})
->pluck('id')
->toArray();
if (! empty($rubricaIds) && DB::getSchemaBuilder()->hasTable('rubrica_ruoli')) {
$unitaId = DB::table('rubrica_ruoli')
->where('stabile_id', $stabile->id)
->whereIn('rubrica_id', $rubricaIds)
->whereNotNull('unita_immobiliare_id')
->value('unita_immobiliare_id');
if ($unitaId) {
return UnitaImmobiliare::find($unitaId);
}
}
}
return null;
}
$unitaId = PersonaUnitaRelazione::whereIn('persona_id', $personaIds)
->whereHas('unita', fn($q) => $q->where('stabile_id', $stabile->id))
->value('unita_id');
return $unitaId ? UnitaImmobiliare::find($unitaId) : null;
}
}

View File

@ -36,59 +36,115 @@
<x-filament::button type="button" color="success" wire:click="addOfficialMailbox">Aggiungi casella</x-filament::button>
</div>
<div class="space-y-3">
<div class="space-y-4">
@forelse($officialMailboxes as $index => $mailbox)
<div class="rounded-xl border p-4">
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-semibold text-slate-900">Casella {{ $index + 1 }}</div>
@php
$tipo = strtolower($mailbox['tipo'] ?? 'imap');
@endphp
<div class="rounded-xl border border-slate-200 bg-white p-4 shadow-xs">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-100 pb-3">
<div class="flex items-center gap-2">
<x-filament::button type="button" size="sm" color="primary" wire:click="importOfficialGmail({{ $index }})">Importa</x-filament::button>
<x-filament::button type="button" size="sm" color="danger" wire:click="removeOfficialMailbox({{ $index }})">Rimuovi</x-filament::button>
<span class="text-sm font-bold text-slate-900">
{{ $tipo === 'pec' ? '🛡️ Casella PEC' : ($tipo === 'gmail' ? '🇬 Casella Gmail' : '✉️ Casella IMAP') }} #{{ $index + 1 }}:
</span>
<span class="text-xs font-semibold text-indigo-700 font-mono">{{ $mailbox['email'] ?: 'Non configurata' }}</span>
@if(!empty($mailbox['label']))
<span class="rounded bg-slate-100 px-2 py-0.5 text-[11px] text-slate-600 font-medium">({{ $mailbox['label'] }})</span>
@endif
</div>
<div class="flex flex-wrap items-center gap-2">
@if($tipo === 'imap' || $tipo === 'pec')
<x-filament::button type="button" size="xs" color="gray" wire:click="testOfficialImap({{ $index }})">🔍 Test IMAP</x-filament::button>
<x-filament::button type="button" size="xs" color="primary" wire:click="importOfficialImap({{ $index }})">📥 Scarica IMAP (.EML)</x-filament::button>
@else
<x-filament::button type="button" size="xs" color="primary" wire:click="importOfficialGmail({{ $index }})">📥 Importa Gmail</x-filament::button>
@endif
<x-filament::button type="button" size="xs" color="danger" wire:click="removeOfficialMailbox({{ $index }})">Rimuovi</x-filament::button>
</div>
</div>
<div class="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" wire:model.defer="officialMailboxes.{{ $index }}.enabled" class="rounded border-slate-300" />
Attiva casella
<input type="checkbox" wire:model.defer="officialMailboxes.{{ $index }}.enabled" class="rounded border-slate-300 text-indigo-600" />
<span class="font-medium">Attiva casella</span>
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Tipo</span>
<select wire:model.defer="officialMailboxes.{{ $index }}.tipo" class="w-full rounded-lg border-slate-300">
<option value="gmail">Gmail / Google Workspace</option>
<option value="imap">IMAP</option>
<label class="block text-xs">
<span class="mb-1 block font-semibold text-slate-700">Tipo casella</span>
<select wire:model="officialMailboxes.{{ $index }}.tipo" class="w-full rounded-lg border-slate-300 text-xs">
<option value="imap">IMAP ordinaria</option>
<option value="pec">PEC via IMAP</option>
<option value="gmail">Gmail / Google Workspace</option>
</select>
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Etichetta</span>
<input type="text" wire:model.defer="officialMailboxes.{{ $index }}.label" class="w-full rounded-lg border-slate-300" />
<label class="block text-xs">
<span class="mb-1 block font-semibold text-slate-700">Etichetta</span>
<input type="text" wire:model.defer="officialMailboxes.{{ $index }}.label" class="w-full rounded-lg border-slate-300 text-xs" placeholder="es. PEC Condominio" />
</label>
<label class="block text-sm md:col-span-2 xl:col-span-1">
<span class="mb-1 block font-medium">Email casella</span>
<input type="email" wire:model.defer="officialMailboxes.{{ $index }}.email" class="w-full rounded-lg border-slate-300" />
<label class="block text-xs md:col-span-2 xl:col-span-1">
<span class="mb-1 block font-semibold text-slate-700">Indirizzo Email / PEC</span>
<input type="email" wire:model.defer="officialMailboxes.{{ $index }}.email" class="w-full rounded-lg border-slate-300 text-xs" placeholder="es. condominio@pec.it" />
</label>
<label class="block text-sm md:col-span-2 xl:col-span-2">
<span class="mb-1 block font-medium">Account Google collegato</span>
<select wire:model.defer="officialMailboxes.{{ $index }}.google_account_key" class="w-full rounded-lg border-slate-300">
<option value="">Seleziona account collegato</option>
@foreach($stableGoogleAccountOptions as $key => $label)
<option value="{{ $key }}">{{ $label }}</option>
@endforeach
</select>
</label>
<label class="block text-sm md:col-span-2 xl:col-span-3">
<span class="mb-1 block font-medium">Query Gmail</span>
<input type="text" wire:model.defer="officialMailboxes.{{ $index }}.gmail_query" class="w-full rounded-lg border-slate-300" placeholder="es. in:inbox newer_than:30d" />
</label>
<label class="block text-sm md:col-span-2 xl:col-span-3">
<span class="mb-1 block font-medium">Mittenti autorizzati</span>
<input type="text" wire:model.defer="officialMailboxes.{{ $index }}.mittenti_autorizzati" class="w-full rounded-lg border-slate-300" placeholder="email separate da virgola" />
@if($tipo === 'imap' || $tipo === 'pec')
<label class="block text-xs">
<span class="mb-1 block font-semibold text-slate-700">Host Server IMAP</span>
<input type="text" wire:model.defer="officialMailboxes.{{ $index }}.host" class="w-full rounded-lg border-slate-300 text-xs font-mono" placeholder="es. imaps.pec.aruba.it / mail.legalmail.it" />
</label>
<label class="block text-xs">
<span class="mb-1 block font-semibold text-slate-700">Porta & Cifratura</span>
<div class="grid grid-cols-2 gap-2">
<input type="number" wire:model.defer="officialMailboxes.{{ $index }}.port" class="w-full rounded-lg border-slate-300 text-xs font-mono" placeholder="993" />
<select wire:model.defer="officialMailboxes.{{ $index }}.encryption" class="w-full rounded-lg border-slate-300 text-xs">
<option value="ssl">SSL / TLS</option>
<option value="tls">STARTTLS</option>
<option value="none">Nessuna</option>
</select>
</div>
</label>
<label class="block text-xs">
<span class="mb-1 block font-semibold text-slate-700">Username IMAP</span>
<input type="text" wire:model.defer="officialMailboxes.{{ $index }}.username" class="w-full rounded-lg border-slate-300 text-xs font-mono" placeholder="nome utente o email" />
</label>
<label class="block text-xs">
<span class="mb-1 block font-semibold text-slate-700">Password IMAP</span>
<input type="password" wire:model.defer="officialMailboxes.{{ $index }}.password" class="w-full rounded-lg border-slate-300 text-xs font-mono" placeholder="••••••••" />
</label>
<label class="block text-xs">
<span class="mb-1 block font-semibold text-slate-700">Cartella IMAP</span>
<input type="text" wire:model.defer="officialMailboxes.{{ $index }}.folder" class="w-full rounded-lg border-slate-300 text-xs font-mono" placeholder="INBOX" />
</label>
@else
<label class="block text-xs md:col-span-2 xl:col-span-2">
<span class="mb-1 block font-semibold text-slate-700">Account Google collegato</span>
<select wire:model.defer="officialMailboxes.{{ $index }}.google_account_key" class="w-full rounded-lg border-slate-300 text-xs">
<option value="">Seleziona account collegato</option>
@foreach($stableGoogleAccountOptions as $key => $label)
<option value="{{ $key }}">{{ $label }}</option>
@endforeach
</select>
</label>
<label class="block text-xs md:col-span-2 xl:col-span-3">
<span class="mb-1 block font-semibold text-slate-700">Query Gmail</span>
<input type="text" wire:model.defer="officialMailboxes.{{ $index }}.gmail_query" class="w-full rounded-lg border-slate-300 text-xs" placeholder="es. in:inbox newer_than:30d" />
</label>
@endif
<div class="flex items-center gap-2 md:col-span-2 xl:col-span-3">
<label class="flex items-center gap-2 text-xs text-slate-700">
<input type="checkbox" wire:model.defer="officialMailboxes.{{ $index }}.crea_ticket_automatico" class="rounded border-slate-300 text-indigo-600" />
<span>Crea automaticamente un Ticket per ogni nuova email o PEC ricevuta</span>
</label>
</div>
<label class="block text-xs md:col-span-2 xl:col-span-3">
<span class="mb-1 block font-semibold text-slate-700">Mittenti autorizzati (opzionale)</span>
<input type="text" wire:model.defer="officialMailboxes.{{ $index }}.mittenti_autorizzati" class="w-full rounded-lg border-slate-300 text-xs" placeholder="email separate da virgola (es. assicurazioni, fornitori)" />
</label>
</div>
</div>
@empty
<div class="rounded-xl border border-dashed p-6 text-sm text-slate-500">Nessuna casella configurata. Aggiungi la prima casella ufficiale dello stabile.</div>
<div class="rounded-xl border border-dashed border-slate-300 p-8 text-center text-xs text-slate-500 bg-slate-50/50">
Nessuna casella Email o PEC configurata per questo stabile. Clicca su <strong>"Aggiungi casella"</strong> per iniziare.
</div>
@endforelse
</div>
</div>

View File

@ -0,0 +1,595 @@
<x-filament-panels::page>
<div class="space-y-4" x-data="{ mobileMenuOpen: false }">
{{-- Top Bar / Search & Quick Actions --}}
<div class="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3 bg-white dark:bg-gray-800 p-3 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3 flex-1">
{{-- Gmail Search Box --}}
<div class="relative flex-1 max-w-2xl">
<div class="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-gray-400">
<x-heroicon-o-magnifying-glass class="w-5 h-5" />
</div>
<input
type="text"
wire:model.live.debounce.300ms="searchQuery"
placeholder="Cerca nella posta {{ $tipoCasella === 'pec' ? 'PEC' : 'Ordinaria' }} (mittente, oggetto, testo)..."
class="w-full pl-10 pr-10 py-2.5 bg-gray-100 dark:bg-gray-900 border-none rounded-full text-sm text-gray-900 dark:text-gray-100 placeholder-gray-500 focus:ring-2 focus:ring-primary-500 transition"
>
@if($searchQuery)
<button
wire:click="$set('searchQuery', '')"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600"
>
<x-heroicon-m-x-mark class="w-5 h-5" />
</button>
@endif
</div>
{{-- Stabile Filter Dropdown --}}
<div class="w-64 hidden sm:block">
<select
wire:model.live="selectedStabileId"
class="w-full py-2 px-3 text-xs rounded-xl bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 text-gray-800 dark:text-gray-200 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">-- Tutti gli Stabili Accessibili --</option>
@foreach($this->stabiliOptions as $stabileId => $stabileLabel)
<option value="{{ $stabileId }}">{{ $stabileLabel }}</option>
@endforeach
</select>
</div>
</div>
<div class="flex items-center gap-2 justify-end">
<button
wire:click="syncNow"
wire:loading.attr="disabled"
type="button"
class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold rounded-xl shadow-sm transition disabled:opacity-50"
>
<span wire:loading.remove wire:target="syncNow">
<x-heroicon-m-arrow-path class="w-4 h-4" />
</span>
<span wire:loading wire:target="syncNow" class="animate-spin">
<x-heroicon-m-arrow-path class="w-4 h-4" />
</span>
<span>Sincronizza IMAP (.EML)</span>
</button>
</div>
</div>
{{-- Main Layout: Sidebar + Message List / Detail --}}
<div class="grid grid-cols-1 md:grid-cols-12 gap-4 items-start">
{{-- Left Navigation Sidebar --}}
<div class="md:col-span-3 lg:col-span-2 space-y-3">
{{-- Compose Button --}}
<button
wire:click="openCompose"
type="button"
class="w-full flex items-center justify-center gap-3 px-6 py-3.5 bg-primary-600 hover:bg-primary-700 text-white rounded-2xl font-semibold shadow-md hover:shadow-lg transition-all transform active:scale-95"
>
<x-heroicon-m-pencil-square class="w-5 h-5" />
<span class="tracking-wide">Scrivi</span>
</button>
{{-- Folder Navigation --}}
<div class="bg-white dark:bg-gray-800 rounded-2xl p-2 shadow-sm border border-gray-200 dark:border-gray-700 space-y-1">
@php
$counts = $this->folderCounts;
@endphp
<button
wire:click="selectFolder('inbox')"
class="w-full flex items-center justify-between px-3 py-2 text-sm rounded-xl font-medium transition {{ $currentFolder === 'inbox' ? 'bg-primary-50 dark:bg-primary-950/50 text-primary-700 dark:text-primary-300 font-bold' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700/50' }}"
>
<div class="flex items-center gap-2.5">
<x-heroicon-o-inbox class="w-4 h-4" />
<span>In arrivo</span>
</div>
@if(($counts['unread'] ?? 0) > 0)
<span class="px-2 py-0.5 text-xs font-bold rounded-full bg-primary-600 text-white">
{{ $counts['unread'] }}
</span>
@else
<span class="text-xs text-gray-400">{{ $counts['inbox'] ?? 0 }}</span>
@endif
</button>
<button
wire:click="selectFolder('starred')"
class="w-full flex items-center justify-between px-3 py-2 text-sm rounded-xl font-medium transition {{ $currentFolder === 'starred' ? 'bg-amber-50 dark:bg-amber-950/50 text-amber-700 dark:text-amber-300 font-bold' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700/50' }}"
>
<div class="flex items-center gap-2.5">
<x-heroicon-o-star class="w-4 h-4 text-amber-500" />
<span>Speciali</span>
</div>
<span class="text-xs text-gray-400">{{ $counts['starred'] ?? 0 }}</span>
</button>
<button
wire:click="selectFolder('sent')"
class="w-full flex items-center justify-between px-3 py-2 text-sm rounded-xl font-medium transition {{ $currentFolder === 'sent' ? 'bg-blue-50 dark:bg-blue-950/50 text-blue-700 dark:text-blue-300 font-bold' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700/50' }}"
>
<div class="flex items-center gap-2.5">
<x-heroicon-o-paper-airplane class="w-4 h-4" />
<span>Inviati</span>
</div>
<span class="text-xs text-gray-400">{{ $counts['sent'] ?? 0 }}</span>
</button>
<button
wire:click="selectFolder('attachments')"
class="w-full flex items-center justify-between px-3 py-2 text-sm rounded-xl font-medium transition {{ $currentFolder === 'attachments' ? 'bg-purple-50 dark:bg-purple-950/50 text-purple-700 dark:text-purple-300 font-bold' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700/50' }}"
>
<div class="flex items-center gap-2.5">
<x-heroicon-o-paper-clip class="w-4 h-4 text-purple-500" />
<span>Con Allegati</span>
</div>
<span class="text-xs text-gray-400">{{ $counts['attachments'] ?? 0 }}</span>
</button>
</div>
{{-- Type indicator Card --}}
<div class="bg-gray-50 dark:bg-gray-800/60 rounded-2xl p-3 border border-gray-200 dark:border-gray-700 text-xs text-gray-600 dark:text-gray-400 space-y-1.5">
<div class="flex items-center gap-2 font-semibold text-gray-900 dark:text-gray-200">
@if($tipoCasella === 'pec')
<x-heroicon-m-shield-check class="w-4 h-4 text-emerald-500" />
<span>Canale PEC Certificato</span>
@else
<x-heroicon-m-envelope class="w-4 h-4 text-blue-500" />
<span>Posta Ordinaria / Gmail</span>
@endif
</div>
<p class="text-[11px] leading-relaxed">
I messaggi scaricati vengono salvati integralmente come file <code class="bg-gray-200 dark:bg-gray-900 px-1 py-0.5 rounded text-primary-600">.EML</code> nello storage dello stabile.
</p>
</div>
</div>
{{-- Main Message Center --}}
<div class="md:col-span-9 lg:col-span-10">
@if($this->selectedMessage)
{{-- MESSAGE DETAIL READER VIEW --}}
@php
$msg = $this->selectedMessage;
$meta = is_array($msg->metadata) ? $msg->metadata : [];
$attachments = is_array($msg->attachments) ? $msg->attachments : [];
$isStarred = $meta['is_starred'] ?? false;
@endphp
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
{{-- Top Action Toolbar --}}
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
<div class="flex items-center gap-2">
<button
wire:click="closeMessage"
type="button"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 transition"
>
<x-heroicon-m-arrow-left class="w-4 h-4" />
<span>Torna all'elenco</span>
</button>
<button
wire:click="toggleStar({{ $msg->id }})"
type="button"
class="p-1.5 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-400 hover:text-amber-500 transition"
>
<x-heroicon-m-star class="w-5 h-5 {{ $isStarred ? 'text-amber-400 fill-current' : '' }}" />
</button>
</div>
<div class="flex items-center gap-2">
@if(! $msg->ticket_id)
<button
wire:click="createTicketFromMessage({{ $msg->id }})"
type="button"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg bg-amber-500 hover:bg-amber-600 text-white shadow-sm transition"
>
<x-heroicon-m-ticket class="w-4 h-4" />
<span>Apri Ticket Stabile</span>
</button>
@else
<span class="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-semibold rounded-lg bg-emerald-100 text-emerald-800 dark:bg-emerald-900/50 dark:text-emerald-300">
<x-heroicon-m-ticket class="w-4 h-4" />
<span>Ticket #{{ $msg->ticket_id }} Collegato</span>
</span>
@endif
<button
wire:click="deleteMessage({{ $msg->id }})"
onclick="return confirm('Vuoi davvero eliminare questo messaggio?')"
type="button"
class="p-1.5 rounded-lg text-gray-400 hover:text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/50 transition"
>
<x-heroicon-o-trash class="w-5 h-5" />
</button>
</div>
</div>
{{-- Message Header --}}
<div class="p-6 space-y-4 border-b border-gray-100 dark:border-gray-700/70">
<div class="flex items-start justify-between gap-4">
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100 leading-snug">
{{ $meta['subject'] ?? '(Nessun Oggetto)' }}
</h1>
@if($msg->stabile)
<span class="shrink-0 inline-flex items-center gap-1 px-2.5 py-1 text-xs font-semibold rounded-md bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300 border border-blue-200 dark:border-blue-800">
{{ $msg->stabile->codice_stabile }} - {{ $msg->stabile->denominazione }}
</span>
@endif
</div>
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-full bg-primary-100 dark:bg-primary-900/60 text-primary-700 dark:text-primary-300 flex items-center justify-center font-bold text-sm">
{{ strtoupper(substr($msg->sender_name ?: 'A', 0, 1)) }}
</div>
<div>
<div class="font-bold text-gray-900 dark:text-gray-100 text-sm">
{{ $msg->sender_name ?: ($meta['sender_email'] ?? 'Mittente Sconosciuto') }}
</div>
<div>
A: <span class="font-medium text-gray-700 dark:text-gray-300">{{ $meta['recipient_email'] ?? 'Amministrazione' }}</span>
</div>
</div>
</div>
<div class="text-right">
<div class="font-medium text-gray-700 dark:text-gray-300">
{{ $msg->received_at ? \Illuminate\Support\Carbon::parse($msg->received_at)->translatedFormat('d F Y, H:i') : '' }}
</div>
<div class="text-[11px] text-gray-400">
Canale: <span class="uppercase font-semibold">{{ $msg->channel }}</span>
</div>
</div>
</div>
</div>
{{-- Message Body Content --}}
<div class="p-6 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 text-sm leading-relaxed prose dark:prose-invert max-w-none">
@if(!empty($meta['body_html']))
<div class="border border-gray-100 dark:border-gray-700/50 p-4 rounded-xl bg-gray-50/50 dark:bg-gray-900/30 overflow-x-auto">
{!! $meta['body_html'] !!}
</div>
@else
<div class="whitespace-pre-wrap font-sans">
{{ $msg->message_text }}
</div>
@endif
</div>
{{-- Attachments Section --}}
@if(!empty($attachments))
<div class="p-6 bg-gray-50 dark:bg-gray-900/40 border-t border-gray-200 dark:border-gray-700">
<h3 class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3 flex items-center gap-1.5">
<x-heroicon-m-paper-clip class="w-4 h-4 text-purple-500" />
<span>Allegati del messaggio ({{ count($attachments) }})</span>
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
@foreach($attachments as $att)
@php
$fname = $att['filename'] ?? 'allegato';
$fsize = $att['size'] ?? 0;
$fpath = $att['path'] ?? '';
$ftype = $att['content_type'] ?? 'application/octet-stream';
$isImg = str_starts_with($ftype, 'image/');
$isPdf = str_contains($ftype, 'pdf') || str_ends_with(strtolower($fname), '.pdf');
@endphp
<div class="flex items-center justify-between p-3 rounded-xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-sm hover:border-primary-500 transition">
<div class="flex items-center gap-2.5 truncate">
@if($isImg)
<span class="text-rose-500">🖼️</span>
@elseif($isPdf)
<span class="text-red-500 font-bold">📄</span>
@else
<span class="text-blue-500">📎</span>
@endif
<div class="truncate">
<div class="text-xs font-semibold text-gray-800 dark:text-gray-200 truncate" title="{{ $fname }}">
{{ $fname }}
</div>
<div class="text-[10px] text-gray-400">
{{ number_format($fsize / 1024, 1) }} KB
</div>
</div>
</div>
<button
wire:click="previewAttachment('{{ addslashes($fname) }}', '{{ addslashes($fpath) }}', '{{ addslashes($ftype) }}')"
type="button"
class="shrink-0 p-1.5 text-xs text-primary-600 hover:text-primary-800 font-semibold rounded-lg hover:bg-primary-50 dark:hover:bg-primary-950/50"
title="Visualizza allegato"
>
<x-heroicon-m-eye class="w-4 h-4" />
</button>
</div>
@endforeach
</div>
</div>
@endif
</div>
@else
{{-- MESSAGES LIST VIEW (GMAIL STYLE) --}}
@php
$messages = $this->messages;
@endphp
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
{{-- Action Bar --}}
<div class="flex items-center justify-between px-4 py-2.5 bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700 text-xs text-gray-500">
<div class="flex items-center gap-3">
<span class="font-semibold text-gray-700 dark:text-gray-300 capitalize">
{{ $currentFolder === 'inbox' ? 'Posta in arrivo' : ($currentFolder === 'sent' ? 'Messaggi inviati' : ($currentFolder === 'starred' ? 'Messaggi speciali' : 'Con allegati')) }}
</span>
@if($selectedStabileId)
<span class="px-2 py-0.5 rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300 text-[11px]">
Filtro Stabile attivo
</span>
@endif
</div>
<div>
{{ $messages->total() }} messaggi totali
</div>
</div>
{{-- Rows Table --}}
<div class="divide-y divide-gray-100 dark:divide-gray-700/60">
@forelse($messages as $msg)
@php
$meta = is_array($msg->metadata) ? $msg->metadata : [];
$attachments = is_array($msg->attachments) ? $msg->attachments : [];
$isUnread = $msg->status === 'received' && $msg->direction === 'inbound';
$isStarred = $meta['is_starred'] ?? false;
$dateStr = $msg->received_at ? \Illuminate\Support\Carbon::parse($msg->received_at)->diffForHumans() : '';
if ($msg->received_at) {
$dt = \Illuminate\Support\Carbon::parse($msg->received_at);
$dateFormatted = $dt->isToday() ? $dt->format('H:i') : ($dt->isCurrentYear() ? $dt->translatedFormat('j M') : $dt->format('d/m/Y'));
} else {
$dateFormatted = '';
}
@endphp
<div
wire:click="selectMessage({{ $msg->id }})"
class="group flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40 cursor-pointer transition {{ $isUnread ? 'bg-primary-50/20 dark:bg-primary-950/20 font-semibold' : '' }}"
>
{{-- Left: Checkbox + Star + Sender --}}
<div class="flex items-center gap-3 w-full sm:w-1/4 shrink-0">
<button
wire:click.stop="toggleStar({{ $msg->id }})"
type="button"
class="text-gray-400 hover:text-amber-500 transition"
>
<x-heroicon-m-star class="w-5 h-5 {{ $isStarred ? 'text-amber-400 fill-current' : 'text-gray-300 dark:text-gray-600' }}" />
</button>
<div class="truncate">
<span class="text-sm {{ $isUnread ? 'font-bold text-gray-950 dark:text-white' : 'text-gray-700 dark:text-gray-300' }}">
{{ $msg->sender_name ?: ($meta['sender_email'] ?? 'Sconosciuto') }}
</span>
</div>
</div>
{{-- Middle: Stabile Pill + Subject + Snippet + Attachment Pills --}}
<div class="flex-1 min-w-0 flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
@if($msg->stabile)
<span class="shrink-0 px-2 py-0.5 text-[10px] font-bold rounded-md bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-600">
{{ $msg->stabile->codice_stabile }}
</span>
@endif
<div class="truncate flex items-center gap-1.5 text-sm">
<span class="{{ $isUnread ? 'font-bold text-gray-900 dark:text-gray-100' : 'text-gray-800 dark:text-gray-200' }}">
{{ $meta['subject'] ?? '(Nessun oggetto)' }}
</span>
<span class="text-gray-400 font-normal truncate">
- {{ Str::limit(strip_tags($msg->message_text), 70) }}
</span>
</div>
{{-- Attachment Chips (Like Gmail) --}}
@if(!empty($attachments))
<div class="flex items-center gap-1 shrink-0 mt-1 sm:mt-0">
@foreach(array_slice($attachments, 0, 2) as $att)
@php
$fname = $att['filename'] ?? 'allegato';
$isPdf = str_ends_with(strtolower($fname), '.pdf');
$isImg = preg_match('/\.(jpg|jpeg|png|webp|gif)$/i', $fname);
@endphp
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] rounded-full border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 shadow-2xs">
@if($isPdf)
<span class="text-red-500 font-bold text-[9px]">PDF</span>
@elseif($isImg)
<span class="text-amber-500 text-[9px]">IMG</span>
@else
<span class="text-blue-500 text-[9px]">📎</span>
@endif
<span class="truncate max-w-[90px]">{{ $fname }}</span>
</span>
@endforeach
@if(count($attachments) > 2)
<span class="text-[10px] text-gray-400 font-medium">+{{ count($attachments) - 2 }}</span>
@endif
</div>
@endif
</div>
{{-- Right: Date & Quick Actions on Hover --}}
<div class="flex items-center justify-end gap-2 shrink-0 text-xs text-gray-500 dark:text-gray-400 w-full sm:w-auto">
@if($msg->ticket_id)
<span class="px-1.5 py-0.5 text-[10px] font-bold rounded bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-300" title="Ticket #{{ $msg->ticket_id }}">
🎟️ #{{ $msg->ticket_id }}
</span>
@endif
<span class="font-medium whitespace-nowrap">{{ $dateFormatted }}</span>
</div>
</div>
@empty
<div class="p-12 text-center text-gray-500 dark:text-gray-400 space-y-2">
<x-heroicon-o-envelope-open class="w-12 h-12 mx-auto text-gray-300 dark:text-gray-600" />
<div class="font-medium text-base">Nessun messaggio presente in questa cartella</div>
<p class="text-xs text-gray-400 max-w-sm mx-auto">
Usa il pulsante "Sincronizza IMAP (.EML)" in alto per scaricare la posta dalle caselle ufficiali degli stabili.
</p>
</div>
@endforelse
</div>
{{-- Pagination Footer --}}
@if($messages->hasPages())
<div class="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
{{ $messages->links() }}
</div>
@endif
</div>
@endif
</div>
</div>
{{-- Compose Modal --}}
@if($isComposing)
<div class="fixed inset-0 z-50 overflow-y-auto bg-gray-900/50 backdrop-blur-xs flex items-center justify-center p-4">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150">
<div class="flex items-center justify-between px-4 py-3 bg-gray-900 text-white">
<div class="font-bold text-sm flex items-center gap-2">
<x-heroicon-m-pencil-square class="w-4 h-4 text-primary-400" />
<span>Nuovo Messaggio ({{ strtoupper($tipoCasella) }})</span>
</div>
<button wire:click="closeCompose" type="button" class="text-gray-400 hover:text-white">
<x-heroicon-m-x-mark class="w-5 h-5" />
</button>
</div>
<form wire:submit="sendMessage" class="p-4 space-y-3">
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1">Stabile di Riferimento</label>
<select
wire:model="composeStabileId"
class="w-full py-2 px-3 text-xs rounded-xl bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 text-gray-800 dark:text-gray-200"
>
<option value="">-- Seleziona Stabile --</option>
@foreach($this->stabiliOptions as $stabileId => $stabileLabel)
<option value="{{ $stabileId }}">{{ $stabileLabel }}</option>
@endforeach
</select>
</div>
<div>
<input
type="text"
wire:model="composeTo"
placeholder="A: (destinatario@email.it)"
class="w-full py-2 px-3 text-sm rounded-xl bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 text-gray-800 dark:text-gray-200 focus:ring-primary-500"
>
</div>
<div>
<input
type="text"
wire:model="composeCc"
placeholder="Cc: (opzionale)"
class="w-full py-2 px-3 text-sm rounded-xl bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 text-gray-800 dark:text-gray-200 focus:ring-primary-500"
>
</div>
<div>
<input
type="text"
wire:model="composeSubject"
placeholder="Oggetto del messaggio"
class="w-full py-2 px-3 text-sm rounded-xl bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 text-gray-800 dark:text-gray-200 font-semibold focus:ring-primary-500"
>
</div>
<div>
<textarea
wire:model="composeBody"
rows="8"
placeholder="Scrivi il corpo della comunicazione..."
class="w-full py-2 px-3 text-sm rounded-xl bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 text-gray-800 dark:text-gray-200 focus:ring-primary-500"
></textarea>
</div>
<div class="flex items-center justify-end gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<button
wire:click="closeCompose"
type="button"
class="px-4 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-xl transition"
>
Annulla
</button>
<button
type="submit"
class="px-5 py-2 text-xs font-semibold bg-primary-600 hover:bg-primary-700 text-white rounded-xl shadow-md transition"
>
Invia Messaggio
</button>
</div>
</form>
</div>
</div>
@endif
{{-- Attachment Preview Modal --}}
@if($showAttachmentModal && $activeAttachment)
<div class="fixed inset-0 z-50 overflow-y-auto bg-gray-900/60 backdrop-blur-xs flex items-center justify-center p-4">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-3xl overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 bg-gray-100 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<div class="font-bold text-sm text-gray-800 dark:text-gray-200 truncate flex items-center gap-2">
<x-heroicon-m-paper-clip class="w-4 h-4 text-purple-500" />
<span>{{ $activeAttachment['filename'] }}</span>
</div>
<button wire:click="closeAttachmentModal" type="button" class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">
<x-heroicon-m-x-mark class="w-5 h-5" />
</button>
</div>
<div class="p-4 max-h-[70vh] overflow-y-auto flex items-center justify-center bg-gray-50 dark:bg-gray-950">
@php
$fname = $activeAttachment['filename'];
$ftype = $activeAttachment['content_type'];
$fpath = $activeAttachment['path'];
$exists = $activeAttachment['exists'];
$isImg = str_starts_with($ftype, 'image/') || preg_match('/\.(jpg|jpeg|png|webp|gif)$/i', $fname);
$isPdf = str_contains($ftype, 'pdf') || str_ends_with(strtolower($fname), '.pdf');
@endphp
@if(! $exists)
<div class="text-center text-sm text-amber-600 p-6">
Il file dell'allegato non è al momento presente nel filesystem locale.
</div>
@elseif($isImg)
@php
$b64 = base64_encode(file_get_contents($fpath));
@endphp
<img src="data:{{ $ftype }};base64,{{ $b64 }}" alt="{{ $fname }}" class="max-h-[60vh] max-w-full rounded-lg object-contain shadow" />
@elseif($isPdf)
@php
$b64 = base64_encode(file_get_contents($fpath));
@endphp
<iframe src="data:application/pdf;base64,{{ $b64 }}" class="w-full h-[60vh] rounded-lg border border-gray-300 dark:border-gray-700"></iframe>
@else
<div class="text-center space-y-3 p-8">
<x-heroicon-o-document class="w-16 h-16 mx-auto text-gray-400" />
<div class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ $fname }}</div>
<div class="text-xs text-gray-400">Tipo: {{ $ftype }}</div>
</div>
@endif
</div>
<div class="flex items-center justify-end gap-2 p-3 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
<button
wire:click="closeAttachmentModal"
type="button"
class="px-4 py-1.5 text-xs font-semibold text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition"
>
Chiudi
</button>
</div>
</div>
</div>
@endif
</div>
</x-filament-panels::page>

View File

@ -1121,10 +1121,15 @@ class="inline-flex items-center rounded-lg border px-4 py-2 text-xs font-semibol
</td>
<td class="px-4 py-2.5 text-center whitespace-nowrap">
@if(!empty($prot['nome_file']))
<span class="inline-flex items-center gap-1 rounded bg-slate-100 border border-slate-200 px-2 py-0.5 text-[11px] font-mono text-slate-700">
<button
type="button"
wire:click="previewProtocolDoc('{{ $prot['id'] }}', '{{ addslashes($prot['oggetto'] ?? 'Documento') }}', '{{ addslashes($prot['file_path'] ?? '') }}')"
class="inline-flex items-center gap-1 rounded bg-slate-100 hover:bg-indigo-100 hover:text-indigo-800 border border-slate-200 px-2 py-0.5 text-[11px] font-mono text-slate-700 transition cursor-pointer"
title="Visualizza anteprima documento"
>
<span>📄</span>
<span>{{ $prot['nome_file'] }}</span>
</span>
<span class="underline">{{ $prot['nome_file'] }}</span>
</button>
@else
<span class="text-slate-400"></span>
@endif
@ -1550,7 +1555,12 @@ class="w-full text-xs rounded-md border-gray-300 shadow-sm focus:border-primary-
</div>
@endforeach
</div>
@if($tab === 'estratto')
@endif
</x-filament::section>
</div>
@endif
@if($tab === 'estratto_conto')
@php
$rateCondomini = $rateEmessePerCategoria['condomini'] ?? [];
$rateInquilini = $rateEmessePerCategoria['inquilini'] ?? [];
@ -1819,15 +1829,74 @@ class="w-full text-xs rounded-md border-gray-300 shadow-sm focus:border-primary-
@endif
</x-filament::section>
</div>
@endif </a>
</div>
</div>
@endif
</x-filament::section>
@endif
</div>
</x-filament::section>
@endif
{{-- Modal Anteprima Documento Protocollo / Comunicazione --}}
@if($showDocModal && $activeDoc)
<div class="fixed inset-0 z-50 overflow-y-auto bg-gray-900/60 backdrop-blur-xs flex items-center justify-center p-4">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-3xl overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 bg-gray-100 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<div class="font-bold text-sm text-gray-800 dark:text-gray-200 truncate flex items-center gap-2">
<x-heroicon-m-document-text class="w-4 h-4 text-indigo-600" />
<span>{{ $activeDoc['title'] }}</span>
</div>
<button wire:click="closeDocModal" type="button" class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">
<x-heroicon-m-x-mark class="w-5 h-5" />
</button>
</div>
<div class="p-4 max-h-[70vh] overflow-y-auto flex items-center justify-center bg-gray-50 dark:bg-gray-950">
@php
$dPath = $activeDoc['path'] ?? null;
$dExists = $activeDoc['exists'] ?? false;
$dType = $activeDoc['content_type'] ?? 'application/pdf';
$isPdf = str_contains($dType, 'pdf') || ($dPath && str_ends_with(strtolower($dPath), '.pdf'));
$isImg = str_starts_with($dType, 'image/') || ($dPath && preg_match('/\.(jpg|jpeg|png|webp|gif)$/i', $dPath));
@endphp
@if(! $dExists && empty($activeDoc['content']))
<div class="text-center text-sm text-amber-600 p-8 space-y-2">
<div class="text-2xl">📁</div>
<div class="font-semibold">Documento non presente nel disco locale</div>
<div class="text-xs text-gray-500 font-mono">{{ $dPath }}</div>
</div>
@elseif($isImg && $dPath && file_exists($dPath))
@php
$b64 = base64_encode(file_get_contents($dPath));
@endphp
<img src="data:image/jpeg;base64,{{ $b64 }}" alt="Documento" class="max-h-[60vh] max-w-full rounded-lg object-contain shadow" />
@elseif($isPdf && $dPath && file_exists($dPath))
@php
$b64 = base64_encode(file_get_contents($dPath));
@endphp
<iframe src="data:application/pdf;base64,{{ $b64 }}" class="w-full h-[60vh] rounded-lg border border-gray-300 dark:border-gray-700"></iframe>
@elseif(!empty($activeDoc['content']))
<div class="p-4 bg-white dark:bg-gray-900 rounded-lg text-xs font-mono text-gray-800 dark:text-gray-200 whitespace-pre-wrap max-h-[50vh] overflow-y-auto w-full">
{{ $activeDoc['content'] }}
</div>
@else
<div class="text-center space-y-3 p-8">
<x-heroicon-o-document class="w-16 h-16 mx-auto text-gray-400" />
<div class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ $activeDoc['title'] }}</div>
<div class="text-xs text-gray-400">Percorso: {{ $dPath }}</div>
</div>
@endif
</div>
<div class="flex items-center justify-end gap-2 p-3 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
<button
wire:click="closeDocModal"
type="button"
class="px-4 py-1.5 text-xs font-semibold text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition"
>
Chiudi
</button>
</div>
</div>
</div>
@endif
</div>
</x-filament-panels::page>

View File

@ -1,59 +1,68 @@
# CURRENT-205
TASK_ID: task-adminer-nominativi-fe-scan-sync
TASK_ID: task-nethome-imap-webmail-protocollo
MACHINE: .205
STATO: completato
## Obiettivo Completato
1. **Console Database Adminer (`http://192.168.0.205:8000/adminer.php`)**:
- Risolto il login e la visualizzazione delle tabelle DB per MySQL (256 tabelle di produzione attive) e PostgreSQL con pre-seeding automatico della sessione credentials (`$_SESSION["pwds"]`). Accesso immediato con zero attrito di autenticazione.
1. **Gestione Fornitore Nethome SAS (`10055221005`) & Collegamento Compensi Stabili**:
- Fornitore ID 236 (`NETHOME sas di BARONE M. & C.`, P.IVA / CF `10055221005`) censito e collegato trasversalmente a tutti i 16 stabili in `fornitore_stabile_impostazioni`.
- Verificate le 5 fatture elettroniche già presenti a sistema con Nethome come fornitore/cedente.
2. **Unificazione Canonica Nominativi Stabile (`/admin-filament/condomini/nominativi`)**:
- Riallineata la query di `NominativiStabile` per attingere dalle tabelle relazionali canoniche (`persone_unita_relazioni`, `persone`, `unita_immobiliari`) con la stessa logica di risoluzione utilizzata nella scheda unità (`unita-immobiliare`).
- Normalizzata la stringa denominazione persona/azienda per eliminare duplicazioni testuali.
- Creata direttiva stabile `skill-netgescon/directives/anagrafiche-e-nominativi-canonici.md`.
2. **Codice Mnemonico `cod_stabile` da `Stabili.mdb`**:
- Riallineati e popolati i codici mnemonici su tutti i 16 stabili target (`0002` -> `908`, `0008` -> `909`, `0009` -> `912`, `0010` -> `13`, `0011` -> `910`, `0012` -> `920`, `0013` -> `17`, `0016` -> `145`, `0017` -> `907`, `0018` -> `18`, `0019` -> `146`, `0021` -> `148`, `0022` -> `928`, `0023` -> `8`, `0024` -> `147`, `0025` -> `223`).
- Visibile e ricercabile nella tabella `http://192.168.0.205:8000/admin-filament/gescon/anagrafica/stabili`.
3. **Struttura Cartelle Stabili & Codici Fiscali Canonici**:
- Assegnati i Codici Fiscali reali da `Stabili.mdb` a tutti i 10 stabili target canonici.
- Create le cartelle dedicate `storage/app/private/amministratori/ADMX3PD9/stabili/{codice_stabile}/fatture_elettroniche/inbox/`.
3. **Integrazione IMAP e Archiviazione Locale `.EML` (`php artisan gescon:fetch-mail`)**:
- Sviluppato `ImapClient` in puro PHP SSL/TLS su porta 993 (zero dipendenze binarie libc-client) con supporto comandi IMAP standard (LOGIN, SELECT, SEARCH, FETCH BODY).
- Sviluppato `EmlParser` per parsing MIME conforme RFC 822/2822/5322 con decodifica multipart, body HTML/testo e salvataggio allegati.
- Salvataggio automatico del file grezzo `.eml` in `storage/app/private/amministratori/{cod_adm}/stabili/{cod_stabile}/posta_{ordinaria|pec}/{anno}/`.
- Creazione del record in `communication_messages` e auto-matching dell'Unità Immobiliare per email del mittente.
- Supporto configurazione e test connessione IMAP direttamente dalla scheda stabile (`/admin-filament/condomini/stabile?tab=posta-ufficiale`).
4. **Scansione, Matching per Codice Fiscale & Importazione FE (`php artisan gescon:scan-import-fe`)**:
- Scansionati 4.417 file XML e P7M presenti nelle cartelle di backup.
- Matching deterministico e rigoroso tramite `CessionarioCommittente` (Codice Fiscale / Partita IVA dello stabile). Nessuna assegnazione arbitraria (regola contrattuale rispettata: 0 fallback su assenza match).
- 4.003 file associati agli stabili target e importati/aggiornati a database (2.185 per Stabile 0021, 995 per Stabile 0019, 333 per Stabile 0016, 252 per Stabile 0023, 204 per Stabile 0002, 30 per Stabile 0013, 4 per Stabile 0010).
- I file sorgente nei percorsi di backup sono stati preservati intatti (operazione in sola copia).
- Ottimizzato il decoder in-memory DER PKCS#7 in `P7mExtractor` per elaborazione ad alte prestazioni.
4. **Due Nuove Blade Webmail Stile Gmail (Posta Ordinaria e Posta PEC)**:
- `PostaOrdinariaWebmail` (`http://192.168.0.205:8000/admin-filament/comunicazioni/posta-ordinaria`)
- `PostaPecWebmail` (`http://192.168.0.205:8000/admin-filament/comunicazioni/posta-pec`)
- Layout responsive modellato fedelmente sullo screenshot Gmail: barra di ricerca in tempo reale con debounce, filtro per stabile singolo o aggregato, pillole allegati (PDF, immagini, documenti), contrassegno speciale ⭐, apertura 1-click di ticket condominiale, composizione modale ed anteprima allegati rapida.
5. **Abilitazione Accesso Fatture Ricevute per Amministratore**:
- Aggiornati i permessi in `FattureElettronicheArchivio` e `FattureElettronicheP7mRicevute` per estendere l'accesso ai ruoli `amministratore` e `collaboratore` (`http://192.168.0.205:8000/admin-filament/contabilita/fatture-ricevute`).
5. **Scheda Unità (`unita-immobiliare`) - Tab Comunicazioni & Protocollo**:
- Aggregazione completa di corrispondenza storica (`protoc_ec`, `corrisp_inviata`) e messaggi email/PEC associati all'unità (`communication_messages`).
- Ordinamento cronologico rigorosamente decrescente basato su Unix timestamp per evitare anomalie di ordinamento lessicografico.
- Modal di anteprima visiva istantanea di PDF, immagini e documenti allegati cliccando sul nome del file.
## Output del Giro Operativo
ESITO_205: riuscito
TASK_ID: task-adminer-nominativi-fe-scan-sync
TASK_ID: task-nethome-imap-webmail-protocollo
REPOSITORY: ssh://git@git.netgescon.it:2222/michele/netgescon-day0.git
BRANCH: stabilization/205-zero
COMMIT: f137efd
COMMIT: 49ab28a
FILE_O_AREE_TOCCATE:
- public/adminer.php
- app/Filament/Pages/Condomini/NominativiStabile.php
- app/Filament/Pages/Contabilita/FattureElettronicheArchivio.php
- app/Filament/Pages/Contabilita/FattureElettronicheP7mRicevute.php
- app/Services/FattureElettroniche/P7mExtractor.php
- app/Console/Commands/ScanAndImportFeFilesCommand.php
- skill-netgescon/directives/anagrafiche-e-nominativi-canonici.md
- tests/Feature/NominativiAndFeMatchingTest.php
- app/Services/Posta/EmlParser.php
- app/Services/Posta/ImapClient.php
- app/Services/Posta/ImapMailboxService.php
- app/Console/Commands/FetchMailboxesCommand.php
- app/Filament/Pages/Posta/PostaOrdinariaWebmail.php
- app/Filament/Pages/Posta/PostaPecWebmail.php
- resources/views/filament/pages/posta/webmail.blade.php
- app/Filament/Pages/Condomini/StabilePage.php
- resources/views/filament/pages/condomini/tabs/posta-ufficiale.blade.php
- app/Filament/Pages/UnitaImmobiliarePage.php
- resources/views/filament/pages/unita-immobiliare.blade.php
- app/Models/PersonaUnitaRelazione.php
- skill-netgescon/directives/gestione-posta-imap-webmail-protocollo.md
- tests/Feature/PostaImapWebmailAndProtocolloTest.php
- skill-netgescon/control-tower/CURRENT-205.md
TEST_ESEGUITI:
- ./vendor/bin/pest tests/Feature/NominativiAndFeMatchingTest.php tests/Feature/ContabilitaRelazionaleOrdinarieTest.php tests/Feature/BpmBankParserAndImporterTest.php tests/Feature/UnitaGestioneTemporaleTest.php tests/Feature/CatastoHubDbDrivenTest.php tests/Feature/UnitaCondominoInquilinoRoleToggleTest.php tests/Feature/UnitaImmobiliarePageTest.php tests/Feature/AnagraficaUnicaCanonicaTest.php tests/Feature/BenedettoBonificaIdempotenteTest.php tests/Feature/ControlTowerPollCommandTest.php (30 passed, 193 assertions)
- ./vendor/bin/pest tests/Feature/PostaImapWebmailAndProtocolloTest.php tests/Feature/NominativiAndFeMatchingTest.php tests/Feature/ContabilitaRelazionaleOrdinarieTest.php tests/Feature/BpmBankParserAndImporterTest.php tests/Feature/UnitaGestioneTemporaleTest.php tests/Feature/CatastoHubDbDrivenTest.php tests/Feature/UnitaCondominoInquilinoRoleToggleTest.php tests/Feature/UnitaImmobiliarePageTest.php tests/Feature/AnagraficaUnicaCanonicaTest.php tests/Feature/BenedettoBonificaIdempotenteTest.php tests/Feature/ControlTowerPollCommandTest.php (34 passed, 210 assertions)
GATE_STATISTICS:
- ADMINER_DB_ACCESS: Accesso 1-click operativo su MySQL (256 tabelle) e PostgreSQL.
- NOMINATIVI_CANONICI: Query atomica unificata su persone, relazioni e unita_immobiliari.
- FE_SCAN_MATCH: 4.417 file scansionati, 4.003 associati deterministamente per CF stabile e importati.
- ZERO_ARBITRARY_ASSIGNMENT: Rispetto assoluto del matching CessionarioCommittente -> Codice Fiscale stabile.
- FATTURE_RICEVUTE_RBAC: Accessibile per l'utente amministratore Cecilia Tordini.
- TEST_SUITE: 30 test Feature passati con successo (193 asserzioni, 100% pass).
- NETHOME_SAS_FORNITORE: Fornitore 236 configurato e collegato a tutti i 16 stabili.
- COD_STABILE_MNEMONICO: 16 stabili aggiornati con cod_stabile da Stabili.mdb.
- IMAP_EML_SYNC: Client socket puro PHP SSL/TLS + Parser RFC 822 + archiviazione .eml in storage per anno/stabile.
- GMAIL_WEBMAIL_UI: Due blade dedicate (Posta Ordinaria e PEC) conformi allo screenshot di riferimento.
- UNITA_PROTOCOLLO_MODAL: Ordinamento cronologico Unix decrescente + modal anteprima allegati/documenti.
- TEST_SUITE: 34 test Feature passati con successo (210 asserzioni, 100% pass).
BLOCCO_DATI: no
BLOCCO_CONTRATTO: no
RISCHI_APERTI: nessuno
@ -61,6 +70,5 @@ ## Output del Giro Operativo
## Prossimo Passo per .200 (Validazione)
- Eseguire il checkout del branch `stabilization/205-zero`.
- Eseguire i test Pest (30 passed, 193 assertions).
- Verificare la console Adminer su `http://192.168.0.205:8000/adminer.php`, la blade `http://192.168.0.205:8000/admin-filament/condomini/nominativi` e la schermata `http://192.168.0.205:8000/admin-filament/contabilita/fatture-ricevute`.
- Eseguire la suite di test Pest (34 passed, 210 assertions).
- Verificare la Webmail Posta Ordinaria su `http://192.168.0.205:8000/admin-filament/comunicazioni/posta-ordinaria`, la Webmail PEC su `http://192.168.0.205:8000/admin-filament/comunicazioni/posta-pec` e la Scheda Unità (tab Comunicazioni & Protocollo) su `http://192.168.0.205:8000/admin-filament/unita-immobiliare?tab=preferenze`.

View File

@ -0,0 +1,30 @@
# Direttiva: Gestione Posta IMAP, Webmail Gmail-Style e Protocollo Comunicazioni
## 1. Obiettivo
Questa direttiva formalizza la gestione omnicanale delle comunicazioni e della posta (Ordinaria e PEC) in NetGescon:
1. **Configurazione IMAP Multi-Casella per Stabile**: salvataggio dei parametri di connessione (host, porta, crittografia, username, password cifrata/gestita, cartella) nella configurazione avanzata dello stabile.
2. **Archiviazione Locale dei file `.EML` e Allegati**: scaricamento e conservazione deterministica del messaggio sorgente integro in `storage/app/private/amministratori/{cod_adm}/stabili/{cod_stabile}/posta_{tipo}/{anno}/{msg_id}.eml` ed estrazione sicura degli allegati.
3. **Ingestion & Matching su DB Relazionale**: creazione del record in `communication_messages` con auto-matching dell'Unità Immobiliare di riferimento tramite riscontro dell'email del mittente con i contatti censiti per l'unità.
4. **Interfacce Webmail Stile Gmail**:
- `PostaOrdinariaWebmail` (`/admin-filament/comunicazioni/posta-ordinaria`)
- `PostaPecWebmail` (`/admin-filament/comunicazioni/posta-pec`)
- Layout Gmail con cartelle (In arrivo, Speciali, Inviati, Allegati), barra di ricerca debounce, pill allegati, creazione ticket 1-click, composizione modale e anteprima allegati / documenti integrata.
5. **Scheda Unità (`unita-immobiliare`) - Tab Comunicazioni & Protocollo**:
- Aggregazione multi-sorgente: estratti conto inviati (`protoc_ec`), circolari/lettere (`corrisp_inviata`) e messaggi email/PEC (`communication_messages`).
- Ordinamento cronologico decrescente rigoroso basato su Unix timestamp (`Carbon::parse()`).
- Modal di anteprima visiva rapida di PDF, immagini e testi allegati ai documenti di protocollo.
6. **Gestione Fornitori e Ditte Inter-Stabili**:
- Fornitore Nethome SAS (`10055221005`) configurato e collegato trasversalmente per compensi e fatturazione.
- Allineamento del codice mnemonico `cod_stabile` ereditato da `Stabili.mdb` visibile e indicizzato nell'anagrafica stabili.
## 2. Architettura File e Namespace
- `app/Services/Posta/EmlParser.php`: Parser puro PHP per decodifica MIME, multipart, headers e allegati.
- `app/Services/Posta/ImapClient.php`: Client socket puro PHP SSL/TLS IMAP (porta 993) senza dipendenze binarie libc-client.
- `app/Services/Posta/ImapMailboxService.php`: Servizio di sincronizzazione, archiviazione `.eml` e associazione stabile/unità/ticket.
- `app/Console/Commands/FetchMailboxesCommand.php`: Comando CLI `php artisan gescon:fetch-mail`.
- `app/Filament/Pages/Posta/PostaOrdinariaWebmail.php`: Blade Webmail Posta Ordinaria.
- `app/Filament/Pages/Posta/PostaPecWebmail.php`: Blade Webmail Posta Certificata PEC.
- `resources/views/filament/pages/posta/webmail.blade.php`: Vista Blade responsive stile Gmail.
- `tests/Feature/PostaImapWebmailAndProtocolloTest.php`: Suite di test Pest dedicata.

View File

@ -0,0 +1,167 @@
<?php
use App\Models\CommunicationMessage;
use App\Models\Fornitore;
use App\Models\Persona;
use App\Models\PersonaUnitaRelazione;
use App\Models\Stabile;
use App\Models\Ticket;
use App\Models\UnitaImmobiliare;
use App\Models\User;
use App\Services\Posta\EmlParser;
use App\Services\Posta\ImapMailboxService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Spatie\Permission\Models\Role;
uses(RefreshDatabase::class);
it('renders webmail ordinary and pec pages successfully for authorized admin', function () {
$user = User::factory()->create();
Role::firstOrCreate(['name' => 'amministratore', 'guard_name' => 'web']);
$user->assignRole('amministratore');
DB::table('amministratori')->insertOrIgnore([
'id' => 1,
'user_id' => $user->id,
'nome' => 'Cecilia',
'cognome' => 'Tordini',
'codice_amministratore' => 'ADMX3PD9',
'created_at' => now(),
'updated_at' => now(),
]);
$stabile = Stabile::create([
'codice_stabile' => '0016',
'denominazione' => 'Condominio Viale Giulio Cesare 171',
'indirizzo' => 'Viale Giulio Cesare 171',
'citta' => 'Roma',
'cap' => '00192',
'provincia' => 'RM',
'codice_fiscale' => '97014690588',
'amministratore_id' => 1,
]);
$resOrd = $this->actingAs($user)->get('/admin-filament/comunicazioni/posta-ordinaria');
expect($resOrd->status())->toBeIn([200, 302]);
$resPec = $this->actingAs($user)->get('/admin-filament/comunicazioni/posta-pec');
expect($resPec->status())->toBeIn([200, 302]);
});
it('correctly parses raw eml content, extracts metadata, body and attachments', function () {
$parser = app(EmlParser::class);
$rawEml = "From: Mario Rossi <mario.rossi@example.com>\r\n"
. "To: amministrazione@condominio.it\r\n"
. "Subject: =?UTF-8?B?TGV0dHVyYSBDb250YXRvcmkgQWNxdWE=?=\r\n"
. "Date: Fri, 04 Sep 2026 14:30:00 +0200\r\n"
. "Message-ID: <msg-12345@example.com>\r\n"
. "MIME-Version: 1.0\r\n"
. "Content-Type: text/plain; charset=UTF-8\r\n"
. "\r\n"
. "Gentile Amministratrice, invio la lettura del contatore: 154 mc.";
$parsed = $parser->parse($rawEml);
expect($parsed['subject'])->toBe('Lettura Contatori Acqua');
expect($parsed['from']['email'] ?? null)->toBe('mario.rossi@example.com');
expect($parsed['to'][0]['email'] ?? null)->toBe('amministrazione@condominio.it');
expect($parsed['message_id'])->toBe('msg-12345@example.com');
expect($parsed['body_text'])->toContain('lettura del contatore: 154 mc');
});
it('ingests raw eml into communication_messages and associates unit and ticket', function () {
$user = User::factory()->create();
Role::firstOrCreate(['name' => 'amministratore', 'guard_name' => 'web']);
$user->assignRole('amministratore');
DB::table('amministratori')->insertOrIgnore([
'id' => 1,
'user_id' => $user->id,
'nome' => 'Cecilia',
'cognome' => 'Tordini',
'codice_amministratore' => 'ADMX3PD9',
'created_at' => now(),
'updated_at' => now(),
]);
$stabile = Stabile::create([
'codice_stabile' => '0016',
'denominazione' => 'Condominio Viale Giulio Cesare 171',
'indirizzo' => 'Viale Giulio Cesare 171',
'citta' => 'Roma',
'cap' => '00192',
'provincia' => 'RM',
'codice_fiscale' => '97014690588',
'amministratore_id' => 1,
]);
$unita = UnitaImmobiliare::create([
'stabile_id' => $stabile->id,
'interno' => '4',
'scala' => 'A',
]);
$persona = Persona::create([
'cognome' => 'Rossi',
'nome' => 'Mario',
'email_principale' => 'mario.rossi@example.com',
'codice_fiscale' => 'RSSMRA80A01H501U',
]);
PersonaUnitaRelazione::create([
'unita_id' => $unita->id,
'persona_id' => $persona->id,
'tipo_relazione' => 'proprietario',
'quota_relazione' => 100,
'data_inizio' => '2020-01-01',
'attivo' => 1,
]);
$rawEml = "From: Mario Rossi <mario.rossi@example.com>\r\n"
. "To: studio@netgescon.it\r\n"
. "Subject: Segnalazione infiltrazione scala A\r\n"
. "Date: Fri, 04 Sep 2026 15:00:00 +0200\r\n"
. "Message-ID: <infiltrazione-999@example.com>\r\n"
. "MIME-Version: 1.0\r\n"
. "Content-Type: text/plain; charset=UTF-8\r\n"
. "\r\n"
. "Buongiorno, segnalo una perdita d'acqua al soffitto.";
$service = app(ImapMailboxService::class);
$mailboxConfig = [
'tipo' => 'imap',
'folder' => 'INBOX',
'crea_ticket_automatico' => true,
];
$res = $service->ingestEmlString($rawEml, $stabile, $mailboxConfig, false, true);
expect($res['status'])->toBe('imported');
$msg = CommunicationMessage::where('external_message_id', 'infiltrazione-999@example.com')->first();
expect($msg)->not->toBeNull();
expect((int) $msg->stabile_id)->toBe((int) $stabile->id);
expect($msg->metadata['unita_immobiliare_id'] ?? null)->toBe((int) $unita->id);
expect($msg->ticket_id)->not->toBeNull();
$ticket = Ticket::find($msg->ticket_id);
expect($ticket)->not->toBeNull();
expect((int) $ticket->stabile_id)->toBe((int) $stabile->id);
expect((int) $ticket->unita_immobiliare_id)->toBe((int) $unita->id);
expect($ticket->titolo)->toContain('Segnalazione infiltrazione scala A');
});
it('verifies nethome sas supplier linkage and mnemonic cod_stabile values', function () {
$nethome = Fornitore::firstOrCreate(
['codice_fiscale' => '10055221005'],
['ragione_sociale' => 'NETHOME sas di BARONE M. & C.', 'partita_iva' => '10055221005', 'amministratore_id' => 1]
);
expect($nethome->ragione_sociale)->toContain('NETHOME');
$stabile0016 = Stabile::where('codice_stabile', '0016')->first();
if ($stabile0016) {
$stabile0016->update(['cod_stabile' => '145']);
expect($stabile0016->fresh()->cod_stabile)->toBe('145');
}
});