From d7f83469522c800c5ea8d4bb98471b8d8ae36499 Mon Sep 17 00:00:00 2001 From: michele Date: Sun, 6 Sep 2026 20:55:57 +0200 Subject: [PATCH] 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 --- .../Commands/FetchMailboxesCommand.php | 98 +++ app/Filament/Pages/Condomini/StabilePage.php | 62 ++ .../Pages/Posta/PostaOrdinariaWebmail.php | 357 +++++++++++ app/Filament/Pages/Posta/PostaPecWebmail.php | 346 ++++++++++ app/Filament/Pages/UnitaImmobiliarePage.php | 108 +++- app/Models/PersonaUnitaRelazione.php | 8 + app/Services/Posta/EmlParser.php | 273 ++++++++ app/Services/Posta/ImapClient.php | 211 +++++++ app/Services/Posta/ImapMailboxService.php | 298 +++++++++ .../condomini/tabs/posta-ufficiale.blade.php | 128 ++-- .../filament/pages/posta/webmail.blade.php | 595 ++++++++++++++++++ .../pages/unita-immobiliare.blade.php | 89 ++- skill-netgescon/control-tower/CURRENT-205.md | 84 +-- .../gestione-posta-imap-webmail-protocollo.md | 30 + .../PostaImapWebmailAndProtocolloTest.php | 167 +++++ 15 files changed, 2764 insertions(+), 90 deletions(-) create mode 100644 app/Console/Commands/FetchMailboxesCommand.php create mode 100644 app/Filament/Pages/Posta/PostaOrdinariaWebmail.php create mode 100644 app/Filament/Pages/Posta/PostaPecWebmail.php create mode 100644 app/Services/Posta/EmlParser.php create mode 100644 app/Services/Posta/ImapClient.php create mode 100644 app/Services/Posta/ImapMailboxService.php create mode 100644 resources/views/filament/pages/posta/webmail.blade.php create mode 100644 skill-netgescon/directives/gestione-posta-imap-webmail-protocollo.md create mode 100644 tests/Feature/PostaImapWebmailAndProtocolloTest.php diff --git a/app/Console/Commands/FetchMailboxesCommand.php b/app/Console/Commands/FetchMailboxesCommand.php new file mode 100644 index 0000000..42dcf1e --- /dev/null +++ b/app/Console/Commands/FetchMailboxesCommand.php @@ -0,0 +1,98 @@ +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; + } +} diff --git a/app/Filament/Pages/Condomini/StabilePage.php b/app/Filament/Pages/Condomini/StabilePage.php index 25e4416..1878b81 100755 --- a/app/Filament/Pages/Condomini/StabilePage.php +++ b/app/Filament/Pages/Condomini/StabilePage.php @@ -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'); diff --git a/app/Filament/Pages/Posta/PostaOrdinariaWebmail.php b/app/Filament/Pages/Posta/PostaOrdinariaWebmail.php new file mode 100644 index 0000000..3631966 --- /dev/null +++ b/app/Filament/Pages/Posta/PostaOrdinariaWebmail.php @@ -0,0 +1,357 @@ +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(), + ]; + } +} diff --git a/app/Filament/Pages/Posta/PostaPecWebmail.php b/app/Filament/Pages/Posta/PostaPecWebmail.php new file mode 100644 index 0000000..3066d6d --- /dev/null +++ b/app/Filament/Pages/Posta/PostaPecWebmail.php @@ -0,0 +1,346 @@ +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(), + ]; + } +} diff --git a/app/Filament/Pages/UnitaImmobiliarePage.php b/app/Filament/Pages/UnitaImmobiliarePage.php index 6efa38c..6aa3e78 100755 --- a/app/Filament/Pages/UnitaImmobiliarePage.php +++ b/app/Filament/Pages/UnitaImmobiliarePage.php @@ -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 */ @@ -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; diff --git a/app/Models/PersonaUnitaRelazione.php b/app/Models/PersonaUnitaRelazione.php index 553b034..fdc5b51 100755 --- a/app/Models/PersonaUnitaRelazione.php +++ b/app/Models/PersonaUnitaRelazione.php @@ -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 */ diff --git a/app/Services/Posta/EmlParser.php b/app/Services/Posta/EmlParser.php new file mode 100644 index 0000000..2a85e60 --- /dev/null +++ b/app/Services/Posta/EmlParser.php @@ -0,0 +1,273 @@ +, + * cc: array, + * subject: string, + * body_text: string, + * body_html: ?string, + * is_pec: bool, + * attachments: array + * } + */ + 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; + } +} diff --git a/app/Services/Posta/ImapClient.php b/app/Services/Posta/ImapClient.php new file mode 100644 index 0000000..0e86dce --- /dev/null +++ b/app/Services/Posta/ImapClient.php @@ -0,0 +1,211 @@ +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 + */ + 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") : ''; + } +} diff --git a/app/Services/Posta/ImapMailboxService.php b/app/Services/Posta/ImapMailboxService.php new file mode 100644 index 0000000..eae699a --- /dev/null +++ b/app/Services/Posta/ImapMailboxService.php @@ -0,0 +1,298 @@ + 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; + } +} diff --git a/resources/views/filament/pages/condomini/tabs/posta-ufficiale.blade.php b/resources/views/filament/pages/condomini/tabs/posta-ufficiale.blade.php index bc833ae..58b4bb4 100755 --- a/resources/views/filament/pages/condomini/tabs/posta-ufficiale.blade.php +++ b/resources/views/filament/pages/condomini/tabs/posta-ufficiale.blade.php @@ -36,59 +36,115 @@ Aggiungi casella -
+
@forelse($officialMailboxes as $index => $mailbox) -
-
-
Casella {{ $index + 1 }}
+ @php + $tipo = strtolower($mailbox['tipo'] ?? 'imap'); + @endphp +
+
- Importa - Rimuovi + + {{ $tipo === 'pec' ? '🛡️ Casella PEC' : ($tipo === 'gmail' ? '🇬 Casella Gmail' : '✉️ Casella IMAP') }} #{{ $index + 1 }}: + + {{ $mailbox['email'] ?: 'Non configurata' }} + @if(!empty($mailbox['label'])) + ({{ $mailbox['label'] }}) + @endif +
+
+ @if($tipo === 'imap' || $tipo === 'pec') + 🔍 Test IMAP + 📥 Scarica IMAP (.EML) + @else + 📥 Importa Gmail + @endif + Rimuovi
- -
@empty -
Nessuna casella configurata. Aggiungi la prima casella ufficiale dello stabile.
+
+ Nessuna casella Email o PEC configurata per questo stabile. Clicca su "Aggiungi casella" per iniziare. +
@endforelse
diff --git a/resources/views/filament/pages/posta/webmail.blade.php b/resources/views/filament/pages/posta/webmail.blade.php new file mode 100644 index 0000000..3a38d5f --- /dev/null +++ b/resources/views/filament/pages/posta/webmail.blade.php @@ -0,0 +1,595 @@ + +
+ {{-- Top Bar / Search & Quick Actions --}} +
+
+ {{-- Gmail Search Box --}} +
+
+ +
+ + @if($searchQuery) + + @endif +
+ + {{-- Stabile Filter Dropdown --}} + +
+ +
+ +
+
+ + {{-- Main Layout: Sidebar + Message List / Detail --}} +
+ {{-- Left Navigation Sidebar --}} +
+ {{-- Compose Button --}} + + + {{-- Folder Navigation --}} +
+ @php + $counts = $this->folderCounts; + @endphp + + + + + + + + +
+ + {{-- Type indicator Card --}} +
+
+ @if($tipoCasella === 'pec') + + Canale PEC Certificato + @else + + Posta Ordinaria / Gmail + @endif +
+

+ I messaggi scaricati vengono salvati integralmente come file .EML nello storage dello stabile. +

+
+
+ + {{-- Main Message Center --}} +
+ @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 + +
+ {{-- Top Action Toolbar --}} +
+
+ + + +
+ +
+ @if(! $msg->ticket_id) + + @else + + + Ticket #{{ $msg->ticket_id }} Collegato + + @endif + + +
+
+ + {{-- Message Header --}} +
+
+

+ {{ $meta['subject'] ?? '(Nessun Oggetto)' }} +

+ + @if($msg->stabile) + + {{ $msg->stabile->codice_stabile }} - {{ $msg->stabile->denominazione }} + + @endif +
+ +
+
+
+ {{ strtoupper(substr($msg->sender_name ?: 'A', 0, 1)) }} +
+
+
+ {{ $msg->sender_name ?: ($meta['sender_email'] ?? 'Mittente Sconosciuto') }} +
+
+ A: {{ $meta['recipient_email'] ?? 'Amministrazione' }} +
+
+
+ +
+
+ {{ $msg->received_at ? \Illuminate\Support\Carbon::parse($msg->received_at)->translatedFormat('d F Y, H:i') : '' }} +
+
+ Canale: {{ $msg->channel }} +
+
+
+
+ + {{-- Message Body Content --}} +
+ @if(!empty($meta['body_html'])) +
+ {!! $meta['body_html'] !!} +
+ @else +
+ {{ $msg->message_text }} +
+ @endif +
+ + {{-- Attachments Section --}} + @if(!empty($attachments)) +
+

+ + Allegati del messaggio ({{ count($attachments) }}) +

+ +
+ @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 +
+
+ @if($isImg) + 🖼️ + @elseif($isPdf) + 📄 + @else + 📎 + @endif +
+
+ {{ $fname }} +
+
+ {{ number_format($fsize / 1024, 1) }} KB +
+
+
+ + +
+ @endforeach +
+
+ @endif +
+ @else + {{-- MESSAGES LIST VIEW (GMAIL STYLE) --}} + @php + $messages = $this->messages; + @endphp + +
+ {{-- Action Bar --}} +
+
+ + {{ $currentFolder === 'inbox' ? 'Posta in arrivo' : ($currentFolder === 'sent' ? 'Messaggi inviati' : ($currentFolder === 'starred' ? 'Messaggi speciali' : 'Con allegati')) }} + + @if($selectedStabileId) + + Filtro Stabile attivo + + @endif +
+ +
+ {{ $messages->total() }} messaggi totali +
+
+ + {{-- Rows Table --}} +
+ @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 + +
+ {{-- Left: Checkbox + Star + Sender --}} +
+ + +
+ + {{ $msg->sender_name ?: ($meta['sender_email'] ?? 'Sconosciuto') }} + +
+
+ + {{-- Middle: Stabile Pill + Subject + Snippet + Attachment Pills --}} +
+ @if($msg->stabile) + + {{ $msg->stabile->codice_stabile }} + + @endif + +
+ + {{ $meta['subject'] ?? '(Nessun oggetto)' }} + + + - {{ Str::limit(strip_tags($msg->message_text), 70) }} + +
+ + {{-- Attachment Chips (Like Gmail) --}} + @if(!empty($attachments)) +
+ @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 + + @if($isPdf) + PDF + @elseif($isImg) + IMG + @else + 📎 + @endif + {{ $fname }} + + @endforeach + @if(count($attachments) > 2) + +{{ count($attachments) - 2 }} + @endif +
+ @endif +
+ + {{-- Right: Date & Quick Actions on Hover --}} +
+ @if($msg->ticket_id) + + 🎟️ #{{ $msg->ticket_id }} + + @endif + {{ $dateFormatted }} +
+
+ @empty +
+ +
Nessun messaggio presente in questa cartella
+

+ Usa il pulsante "Sincronizza IMAP (.EML)" in alto per scaricare la posta dalle caselle ufficiali degli stabili. +

+
+ @endforelse +
+ + {{-- Pagination Footer --}} + @if($messages->hasPages()) +
+ {{ $messages->links() }} +
+ @endif +
+ @endif +
+
+ + {{-- Compose Modal --}} + @if($isComposing) +
+
+
+
+ + Nuovo Messaggio ({{ strtoupper($tipoCasella) }}) +
+ +
+ +
+
+ + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+
+
+
+ @endif + + {{-- Attachment Preview Modal --}} + @if($showAttachmentModal && $activeAttachment) +
+
+
+
+ + {{ $activeAttachment['filename'] }} +
+ +
+ +
+ @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) +
+ Il file dell'allegato non è al momento presente nel filesystem locale. +
+ @elseif($isImg) + @php + $b64 = base64_encode(file_get_contents($fpath)); + @endphp + {{ $fname }} + @elseif($isPdf) + @php + $b64 = base64_encode(file_get_contents($fpath)); + @endphp + + @else +
+ +
{{ $fname }}
+
Tipo: {{ $ftype }}
+
+ @endif +
+ +
+ +
+
+
+ @endif +
+
diff --git a/resources/views/filament/pages/unita-immobiliare.blade.php b/resources/views/filament/pages/unita-immobiliare.blade.php index c5c3a91..0573380 100755 --- a/resources/views/filament/pages/unita-immobiliare.blade.php +++ b/resources/views/filament/pages/unita-immobiliare.blade.php @@ -1121,10 +1121,15 @@ class="inline-flex items-center rounded-lg border px-4 py-2 text-xs font-semibol @if(!empty($prot['nome_file'])) - + @else @endif @@ -1550,7 +1555,12 @@ class="w-full text-xs rounded-md border-gray-300 shadow-sm focus:border-primary-
@endforeach
- @if($tab === 'estratto') + @endif + + + @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 - @endif - - - @endif - @endif - @endif + + {{-- Modal Anteprima Documento Protocollo / Comunicazione --}} + @if($showDocModal && $activeDoc) +
+
+
+
+ + {{ $activeDoc['title'] }} +
+ +
+ +
+ @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'])) +
+
📁
+
Documento non presente nel disco locale
+
{{ $dPath }}
+
+ @elseif($isImg && $dPath && file_exists($dPath)) + @php + $b64 = base64_encode(file_get_contents($dPath)); + @endphp + Documento + @elseif($isPdf && $dPath && file_exists($dPath)) + @php + $b64 = base64_encode(file_get_contents($dPath)); + @endphp + + @elseif(!empty($activeDoc['content'])) +
+ {{ $activeDoc['content'] }} +
+ @else +
+ +
{{ $activeDoc['title'] }}
+
Percorso: {{ $dPath }}
+
+ @endif +
+ +
+ +
+
+
+ @endif diff --git a/skill-netgescon/control-tower/CURRENT-205.md b/skill-netgescon/control-tower/CURRENT-205.md index defd55e..717301e 100644 --- a/skill-netgescon/control-tower/CURRENT-205.md +++ b/skill-netgescon/control-tower/CURRENT-205.md @@ -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`. diff --git a/skill-netgescon/directives/gestione-posta-imap-webmail-protocollo.md b/skill-netgescon/directives/gestione-posta-imap-webmail-protocollo.md new file mode 100644 index 0000000..78e0fe0 --- /dev/null +++ b/skill-netgescon/directives/gestione-posta-imap-webmail-protocollo.md @@ -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. diff --git a/tests/Feature/PostaImapWebmailAndProtocolloTest.php b/tests/Feature/PostaImapWebmailAndProtocolloTest.php new file mode 100644 index 0000000..5501faf --- /dev/null +++ b/tests/Feature/PostaImapWebmailAndProtocolloTest.php @@ -0,0 +1,167 @@ +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 \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: \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 \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: \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'); + } +});