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:
parent
e0167b04ff
commit
d7f8346952
98
app/Console/Commands/FetchMailboxesCommand.php
Normal file
98
app/Console/Commands/FetchMailboxesCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1369,6 +1369,68 @@ public function importOfficialGmail(?int $index = null): void
|
||||||
->send();
|
->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
|
public function getGoogleSettingsUrl(): string
|
||||||
{
|
{
|
||||||
return GoogleDashboard::getUrl(panel: 'admin-filament');
|
return GoogleDashboard::getUrl(panel: 'admin-filament');
|
||||||
|
|
|
||||||
357
app/Filament/Pages/Posta/PostaOrdinariaWebmail.php
Normal file
357
app/Filament/Pages/Posta/PostaOrdinariaWebmail.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
346
app/Filament/Pages/Posta/PostaPecWebmail.php
Normal file
346
app/Filament/Pages/Posta/PostaPecWebmail.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -161,6 +161,10 @@ public static function canAccess(): bool
|
||||||
|
|
||||||
public string $recapitiViewMode = 'diviso';
|
public string $recapitiViewMode = 'diviso';
|
||||||
|
|
||||||
|
/** Modal Anteprima Documenti e Allegati Comunicazioni */
|
||||||
|
public bool $showDocModal = false;
|
||||||
|
public ?array $activeDoc = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<int, string>
|
* @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
|
public function getProtocolloComunicazioniProperty(): array
|
||||||
{
|
{
|
||||||
if (! $this->unita || ! $this->unita->stabile) {
|
if (! $this->unita || ! $this->unita->stabile) {
|
||||||
|
|
@ -1166,7 +1189,7 @@ public function getProtocolloComunicazioniProperty(): array
|
||||||
|
|
||||||
$results = [];
|
$results = [];
|
||||||
|
|
||||||
// 1. Dati da protoc_ec
|
// 1. Dati da protoc_ec (Estratti conto storici)
|
||||||
if (DbSchema::connection('gescon_import')->hasTable('protoc_ec')) {
|
if (DbSchema::connection('gescon_import')->hasTable('protoc_ec')) {
|
||||||
$ecQuery = DB::connection('gescon_import')->table('protoc_ec')
|
$ecQuery = DB::connection('gescon_import')->table('protoc_ec')
|
||||||
->where('cod_stabile', $codStabile)
|
->where('cod_stabile', $codStabile)
|
||||||
|
|
@ -1180,7 +1203,18 @@ public function getProtocolloComunicazioniProperty(): array
|
||||||
|
|
||||||
foreach ($ecQuery as $r) {
|
foreach ($ecQuery as $r) {
|
||||||
$dtRaw = $r->data_invio ?? null;
|
$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 ?? ''));
|
$pdfName = trim((string) ($r->nome_pdf ?? ''));
|
||||||
$pdfPath = $pdfName !== '' ? "/mnt/gescon-archives/gescon/{$codStabile}/E_C/{$pdfName}" : null;
|
$pdfPath = $pdfName !== '' ? "/mnt/gescon-archives/gescon/{$codStabile}/E_C/{$pdfName}" : null;
|
||||||
$pdfExists = $pdfPath && file_exists($pdfPath);
|
$pdfExists = $pdfPath && file_exists($pdfPath);
|
||||||
|
|
@ -1199,7 +1233,7 @@ public function getProtocolloComunicazioniProperty(): array
|
||||||
'file_path' => $pdfPath,
|
'file_path' => $pdfPath,
|
||||||
'file_exists' => $pdfExists,
|
'file_exists' => $pdfExists,
|
||||||
'note' => $r->note,
|
'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) {
|
foreach ($corrQuery as $r) {
|
||||||
$dtRaw = $r->data_invio ?? null;
|
$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 ?? ''));
|
$docName = trim((string) ($r->lettera_tipo_caricata ?? ''));
|
||||||
$docPath = $docName !== '' ? "/mnt/gescon-archives/gescon/{$codStabile}/{$docName}" : null;
|
$docPath = $docName !== '' ? "/mnt/gescon-archives/gescon/{$codStabile}/{$docName}" : null;
|
||||||
$docExists = $docPath && file_exists($docPath);
|
$docExists = $docPath && file_exists($docPath);
|
||||||
|
|
@ -1239,13 +1284,64 @@ public function getProtocolloComunicazioniProperty(): array
|
||||||
'file_path' => $docPath,
|
'file_path' => $docPath,
|
||||||
'file_exists' => $docExists,
|
'file_exists' => $docExists,
|
||||||
'note' => $r->note,
|
'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) {
|
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;
|
return $results;
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,14 @@ public function unitaImmobiliare(): BelongsTo
|
||||||
return $this->belongsTo(UnitaImmobiliare::class, 'unita_id');
|
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
|
* Tipo di relazione configurabile
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
273
app/Services/Posta/EmlParser.php
Normal file
273
app/Services/Posta/EmlParser.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
211
app/Services/Posta/ImapClient.php
Normal file
211
app/Services/Posta/ImapClient.php
Normal 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") : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
298
app/Services/Posta/ImapMailboxService.php
Normal file
298
app/Services/Posta/ImapMailboxService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,59 +36,115 @@
|
||||||
<x-filament::button type="button" color="success" wire:click="addOfficialMailbox">Aggiungi casella</x-filament::button>
|
<x-filament::button type="button" color="success" wire:click="addOfficialMailbox">Aggiungi casella</x-filament::button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-4">
|
||||||
@forelse($officialMailboxes as $index => $mailbox)
|
@forelse($officialMailboxes as $index => $mailbox)
|
||||||
<div class="rounded-xl border p-4">
|
@php
|
||||||
<div class="flex items-center justify-between gap-3">
|
$tipo = strtolower($mailbox['tipo'] ?? 'imap');
|
||||||
<div class="text-sm font-semibold text-slate-900">Casella {{ $index + 1 }}</div>
|
@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">
|
<div class="flex items-center gap-2">
|
||||||
<x-filament::button type="button" size="sm" color="primary" wire:click="importOfficialGmail({{ $index }})">Importa</x-filament::button>
|
<span class="text-sm font-bold text-slate-900">
|
||||||
<x-filament::button type="button" size="sm" color="danger" wire:click="removeOfficialMailbox({{ $index }})">Rimuovi</x-filament::button>
|
{{ $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>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
<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">
|
<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" />
|
<input type="checkbox" wire:model.defer="officialMailboxes.{{ $index }}.enabled" class="rounded border-slate-300 text-indigo-600" />
|
||||||
Attiva casella
|
<span class="font-medium">Attiva casella</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="block text-sm">
|
<label class="block text-xs">
|
||||||
<span class="mb-1 block font-medium">Tipo</span>
|
<span class="mb-1 block font-semibold text-slate-700">Tipo casella</span>
|
||||||
<select wire:model.defer="officialMailboxes.{{ $index }}.tipo" class="w-full rounded-lg border-slate-300">
|
<select wire:model="officialMailboxes.{{ $index }}.tipo" class="w-full rounded-lg border-slate-300 text-xs">
|
||||||
<option value="gmail">Gmail / Google Workspace</option>
|
<option value="imap">IMAP ordinaria</option>
|
||||||
<option value="imap">IMAP</option>
|
|
||||||
<option value="pec">PEC via IMAP</option>
|
<option value="pec">PEC via IMAP</option>
|
||||||
|
<option value="gmail">Gmail / Google Workspace</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="block text-sm">
|
<label class="block text-xs">
|
||||||
<span class="mb-1 block font-medium">Etichetta</span>
|
<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" />
|
<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>
|
||||||
<label class="block text-sm md:col-span-2 xl:col-span-1">
|
<label class="block text-xs md:col-span-2 xl:col-span-1">
|
||||||
<span class="mb-1 block font-medium">Email casella</span>
|
<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" />
|
<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>
|
||||||
<label class="block text-sm md:col-span-2 xl:col-span-2">
|
|
||||||
<span class="mb-1 block font-medium">Account Google collegato</span>
|
@if($tipo === 'imap' || $tipo === 'pec')
|
||||||
<select wire:model.defer="officialMailboxes.{{ $index }}.google_account_key" class="w-full rounded-lg border-slate-300">
|
<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>
|
<option value="">Seleziona account collegato</option>
|
||||||
@foreach($stableGoogleAccountOptions as $key => $label)
|
@foreach($stableGoogleAccountOptions as $key => $label)
|
||||||
<option value="{{ $key }}">{{ $label }}</option>
|
<option value="{{ $key }}">{{ $label }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="block text-sm md:col-span-2 xl:col-span-3">
|
<label class="block text-xs md:col-span-2 xl:col-span-3">
|
||||||
<span class="mb-1 block font-medium">Query Gmail</span>
|
<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" placeholder="es. in:inbox newer_than:30d" />
|
<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>
|
</label>
|
||||||
<label class="block text-sm md:col-span-2 xl:col-span-3">
|
@endif
|
||||||
<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" />
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@empty
|
@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
|
@endforelse
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
595
resources/views/filament/pages/posta/webmail.blade.php
Normal file
595
resources/views/filament/pages/posta/webmail.blade.php
Normal 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>
|
||||||
|
|
@ -1121,10 +1121,15 @@ class="inline-flex items-center rounded-lg border px-4 py-2 text-xs font-semibol
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2.5 text-center whitespace-nowrap">
|
<td class="px-4 py-2.5 text-center whitespace-nowrap">
|
||||||
@if(!empty($prot['nome_file']))
|
@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>📄</span>
|
||||||
<span>{{ $prot['nome_file'] }}</span>
|
<span class="underline">{{ $prot['nome_file'] }}</span>
|
||||||
</span>
|
</button>
|
||||||
@else
|
@else
|
||||||
<span class="text-slate-400">—</span>
|
<span class="text-slate-400">—</span>
|
||||||
@endif
|
@endif
|
||||||
|
|
@ -1550,7 +1555,12 @@ class="w-full text-xs rounded-md border-gray-300 shadow-sm focus:border-primary-
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@if($tab === 'estratto')
|
@endif
|
||||||
|
</x-filament::section>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($tab === 'estratto_conto')
|
||||||
@php
|
@php
|
||||||
$rateCondomini = $rateEmessePerCategoria['condomini'] ?? [];
|
$rateCondomini = $rateEmessePerCategoria['condomini'] ?? [];
|
||||||
$rateInquilini = $rateEmessePerCategoria['inquilini'] ?? [];
|
$rateInquilini = $rateEmessePerCategoria['inquilini'] ?? [];
|
||||||
|
|
@ -1819,15 +1829,74 @@ class="w-full text-xs rounded-md border-gray-300 shadow-sm focus:border-primary-
|
||||||
@endif
|
@endif
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
</div>
|
</div>
|
||||||
@endif </a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
@endif
|
||||||
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
@endif
|
@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>
|
</div>
|
||||||
</x-filament::section>
|
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</x-filament-panels::page>
|
</x-filament-panels::page>
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,68 @@
|
||||||
# CURRENT-205
|
# CURRENT-205
|
||||||
|
|
||||||
TASK_ID: task-adminer-nominativi-fe-scan-sync
|
TASK_ID: task-nethome-imap-webmail-protocollo
|
||||||
MACHINE: .205
|
MACHINE: .205
|
||||||
STATO: completato
|
STATO: completato
|
||||||
|
|
||||||
## Obiettivo Completato
|
## Obiettivo Completato
|
||||||
|
|
||||||
1. **Console Database Adminer (`http://192.168.0.205:8000/adminer.php`)**:
|
1. **Gestione Fornitore Nethome SAS (`10055221005`) & Collegamento Compensi Stabili**:
|
||||||
- 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.
|
- 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`)**:
|
2. **Codice Mnemonico `cod_stabile` da `Stabili.mdb`**:
|
||||||
- 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`).
|
- 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`).
|
||||||
- Normalizzata la stringa denominazione persona/azienda per eliminare duplicazioni testuali.
|
- Visibile e ricercabile nella tabella `http://192.168.0.205:8000/admin-filament/gescon/anagrafica/stabili`.
|
||||||
- Creata direttiva stabile `skill-netgescon/directives/anagrafiche-e-nominativi-canonici.md`.
|
|
||||||
|
|
||||||
3. **Struttura Cartelle Stabili & Codici Fiscali Canonici**:
|
3. **Integrazione IMAP e Archiviazione Locale `.EML` (`php artisan gescon:fetch-mail`)**:
|
||||||
- Assegnati i Codici Fiscali reali da `Stabili.mdb` a tutti i 10 stabili target canonici.
|
- 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).
|
||||||
- Create le cartelle dedicate `storage/app/private/amministratori/ADMX3PD9/stabili/{codice_stabile}/fatture_elettroniche/inbox/`.
|
- 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`)**:
|
4. **Due Nuove Blade Webmail Stile Gmail (Posta Ordinaria e Posta PEC)**:
|
||||||
- Scansionati 4.417 file XML e P7M presenti nelle cartelle di backup.
|
- `PostaOrdinariaWebmail` (`http://192.168.0.205:8000/admin-filament/comunicazioni/posta-ordinaria`)
|
||||||
- Matching deterministico e rigoroso tramite `CessionarioCommittente` (Codice Fiscale / Partita IVA dello stabile). Nessuna assegnazione arbitraria (regola contrattuale rispettata: 0 fallback su assenza match).
|
- `PostaPecWebmail` (`http://192.168.0.205:8000/admin-filament/comunicazioni/posta-pec`)
|
||||||
- 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).
|
- 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.
|
||||||
- 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.
|
|
||||||
|
|
||||||
5. **Abilitazione Accesso Fatture Ricevute per Amministratore**:
|
5. **Scheda Unità (`unita-immobiliare`) - Tab Comunicazioni & Protocollo**:
|
||||||
- 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`).
|
- 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
|
## Output del Giro Operativo
|
||||||
|
|
||||||
ESITO_205: riuscito
|
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
|
REPOSITORY: ssh://git@git.netgescon.it:2222/michele/netgescon-day0.git
|
||||||
BRANCH: stabilization/205-zero
|
BRANCH: stabilization/205-zero
|
||||||
COMMIT: f137efd
|
COMMIT: 49ab28a
|
||||||
FILE_O_AREE_TOCCATE:
|
FILE_O_AREE_TOCCATE:
|
||||||
- public/adminer.php
|
- app/Services/Posta/EmlParser.php
|
||||||
- app/Filament/Pages/Condomini/NominativiStabile.php
|
- app/Services/Posta/ImapClient.php
|
||||||
- app/Filament/Pages/Contabilita/FattureElettronicheArchivio.php
|
- app/Services/Posta/ImapMailboxService.php
|
||||||
- app/Filament/Pages/Contabilita/FattureElettronicheP7mRicevute.php
|
- app/Console/Commands/FetchMailboxesCommand.php
|
||||||
- app/Services/FattureElettroniche/P7mExtractor.php
|
- app/Filament/Pages/Posta/PostaOrdinariaWebmail.php
|
||||||
- app/Console/Commands/ScanAndImportFeFilesCommand.php
|
- app/Filament/Pages/Posta/PostaPecWebmail.php
|
||||||
- skill-netgescon/directives/anagrafiche-e-nominativi-canonici.md
|
- resources/views/filament/pages/posta/webmail.blade.php
|
||||||
- tests/Feature/NominativiAndFeMatchingTest.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
|
- skill-netgescon/control-tower/CURRENT-205.md
|
||||||
TEST_ESEGUITI:
|
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:
|
GATE_STATISTICS:
|
||||||
- ADMINER_DB_ACCESS: Accesso 1-click operativo su MySQL (256 tabelle) e PostgreSQL.
|
- NETHOME_SAS_FORNITORE: Fornitore 236 configurato e collegato a tutti i 16 stabili.
|
||||||
- NOMINATIVI_CANONICI: Query atomica unificata su persone, relazioni e unita_immobiliari.
|
- COD_STABILE_MNEMONICO: 16 stabili aggiornati con cod_stabile da Stabili.mdb.
|
||||||
- FE_SCAN_MATCH: 4.417 file scansionati, 4.003 associati deterministamente per CF stabile e importati.
|
- IMAP_EML_SYNC: Client socket puro PHP SSL/TLS + Parser RFC 822 + archiviazione .eml in storage per anno/stabile.
|
||||||
- ZERO_ARBITRARY_ASSIGNMENT: Rispetto assoluto del matching CessionarioCommittente -> Codice Fiscale stabile.
|
- GMAIL_WEBMAIL_UI: Due blade dedicate (Posta Ordinaria e PEC) conformi allo screenshot di riferimento.
|
||||||
- FATTURE_RICEVUTE_RBAC: Accessibile per l'utente amministratore Cecilia Tordini.
|
- UNITA_PROTOCOLLO_MODAL: Ordinamento cronologico Unix decrescente + modal anteprima allegati/documenti.
|
||||||
- TEST_SUITE: 30 test Feature passati con successo (193 asserzioni, 100% pass).
|
- TEST_SUITE: 34 test Feature passati con successo (210 asserzioni, 100% pass).
|
||||||
BLOCCO_DATI: no
|
BLOCCO_DATI: no
|
||||||
BLOCCO_CONTRATTO: no
|
BLOCCO_CONTRATTO: no
|
||||||
RISCHI_APERTI: nessuno
|
RISCHI_APERTI: nessuno
|
||||||
|
|
@ -61,6 +70,5 @@ ## Output del Giro Operativo
|
||||||
## Prossimo Passo per .200 (Validazione)
|
## Prossimo Passo per .200 (Validazione)
|
||||||
|
|
||||||
- Eseguire il checkout del branch `stabilization/205-zero`.
|
- Eseguire il checkout del branch `stabilization/205-zero`.
|
||||||
- Eseguire i test Pest (30 passed, 193 assertions).
|
- Eseguire la suite di test Pest (34 passed, 210 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`.
|
- 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`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
167
tests/Feature/PostaImapWebmailAndProtocolloTest.php
Normal file
167
tests/Feature/PostaImapWebmailAndProtocolloTest.php
Normal 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');
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user