468 lines
19 KiB
PHP
468 lines
19 KiB
PHP
<?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;
|
|
}
|
|
|
|
/**
|
|
* Scarica e memorizza le email/PEC dello studio amministratore in formato .EML
|
|
*
|
|
* @param \App\Models\Amministratore $amministratore
|
|
* @param array $mailbox
|
|
* @param int $maxMessages
|
|
* @return array{imported: int, skipped: int, errors: int, messages: array}
|
|
*/
|
|
public function fetchStudioMailbox(\App\Models\Amministratore $amministratore, 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';
|
|
|
|
if ($host === '' || $username === '') {
|
|
return [
|
|
'imported' => 0,
|
|
'skipped' => 0,
|
|
'errors' => 1,
|
|
'message' => 'Parametri IMAP studio incompleti',
|
|
'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');
|
|
|
|
$recentIds = array_slice(array_reverse($msgIds), 0, $maxMessages);
|
|
|
|
foreach ($recentIds as $msgId) {
|
|
try {
|
|
$rawEml = $this->imapClient->fetchRawEml($msgId);
|
|
if (trim($rawEml) === '') {
|
|
continue;
|
|
}
|
|
|
|
$res = $this->ingestStudioEmlString($rawEml, $amministratore, $mailbox, $isPec);
|
|
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 EML dello studio amministratore
|
|
*/
|
|
public function ingestStudioEmlString(
|
|
string $rawEml,
|
|
\App\Models\Amministratore $amministratore,
|
|
array $mailboxConfig = [],
|
|
bool $forcePec = false
|
|
): array {
|
|
$parsed = $this->emlParser->parse($rawEml);
|
|
$messageId = $parsed['message_id'] ?: ('studio_' . md5($rawEml));
|
|
|
|
$existing = CommunicationMessage::whereNull('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');
|
|
|
|
$postaSubdir = $channel === 'pec' ? 'posta_pec' : 'posta_ordinaria';
|
|
$storageDir = $this->pathService->amministratoreAbsolutePath($amministratore, "studio/{$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);
|
|
|
|
$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'],
|
|
];
|
|
}
|
|
}
|
|
|
|
$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' => null,
|
|
'sender_name' => $senderDisplay,
|
|
'phone_number' => null,
|
|
'message_text' => $parsed['body_text'] ?: strip_tags((string) $parsed['body_html']),
|
|
'attachments' => $savedAttachments,
|
|
'ticket_id' => null,
|
|
'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',
|
|
'is_studio' => true,
|
|
'mailbox_label' => $mailboxConfig['label'] ?? 'Studio',
|
|
'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'),
|
|
'has_attachments' => count($savedAttachments) > 0,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|