netgescon-day0/app/Services/Support/GmailTicketImportService.php

417 lines
16 KiB
PHP

<?php
namespace App\Services\Support;
use App\Models\CommunicationMessage;
use App\Models\Documento;
use App\Models\Stabile;
use App\Models\Ticket;
use App\Models\TicketAttachment;
use App\Support\GoogleAccountStore;
use Carbon\Carbon;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
class GmailTicketImportService
{
public function __construct(
private readonly GoogleAccountStore $googleAccountStore,
private readonly TicketAttachmentUploadService $ticketAttachmentUploadService,
) {
}
/**
* @param array<string, mixed> $mailbox
* @return array{mailbox:string,imported:int,skipped:int,attachments:int}
*/
public function importMailbox(Stabile $stabile, array $mailbox, int $maxMessages = 10): array
{
$amministratore = $stabile->amministratore;
if (! $amministratore) {
throw new RuntimeException('Amministratore non disponibile per lo stabile #' . $stabile->id . '.');
}
$openingUserId = (int) ($amministratore->user_id ?? 0);
if ($openingUserId <= 0) {
throw new RuntimeException('Utente amministratore non disponibile per aprire i ticket dello stabile #' . $stabile->id . '.');
}
$accountKey = trim((string) ($mailbox['google_account_key'] ?? ''));
$token = $this->googleAccountStore->resolveAccessToken($amministratore, $accountKey, false);
if ($token === null || $token === '') {
throw new RuntimeException('Token Google non disponibile per la casella Gmail collegata allo stabile #' . $stabile->id . '.');
}
$query = trim((string) ($mailbox['gmail_query'] ?? ''));
$response = Http::withToken($token)
->acceptJson()
->get('https://gmail.googleapis.com/gmail/v1/users/me/messages', array_filter([
'maxResults' => max(1, min($maxMessages, 50)),
'q' => $query !== '' ? $query : null,
], static fn($value) => $value !== null));
if (! $response->successful()) {
throw new RuntimeException('Gmail API non disponibile: ' . $response->body());
}
$imported = 0;
$skipped = 0;
$attachments = 0;
$messages = (array) Arr::get($response->json(), 'messages', []);
foreach ($messages as $row) {
$gmailId = trim((string) ($row['id'] ?? ''));
if ($gmailId === '') {
continue;
}
$message = $this->fetchMessage($token, $gmailId, 'full');
$headers = $this->extractHeaders((array) Arr::get($message, 'payload.headers', []));
$headerMessageId = trim((string) ($headers['message-id'] ?? ''));
$externalId = $headerMessageId !== '' ? $headerMessageId : $gmailId;
if ($this->alreadyImported($externalId, $gmailId)) {
$skipped++;
continue;
}
$bodyText = $this->extractBodyText((array) Arr::get($message, 'payload', []));
$subject = trim((string) ($headers['subject'] ?? ''));
$from = trim((string) ($headers['from'] ?? ''));
$to = trim((string) ($headers['to'] ?? ''));
$date = $headers['date'] ?? null;
$ticket = Ticket::query()->create([
'stabile_id' => (int) $stabile->id,
'aperto_da_user_id' => $openingUserId,
'titolo' => Str::limit($subject !== '' ? $subject : ('Email importata da ' . ($from !== '' ? $from : 'Gmail')), 255),
'descrizione' => $this->buildTicketDescription($stabile, $mailbox, $subject, $from, $to, $date, $bodyText),
'categoria_ticket_id' => null,
'luogo_intervento' => 'Casella email stabile',
'data_apertura' => $this->normalizeDate($date) ?? now(),
'stato' => 'Aperto',
'priorita' => 'Media',
]);
$rawMessage = $this->fetchMessage($token, $gmailId, 'raw');
$rawEml = $this->decodeBase64Url((string) Arr::get($rawMessage, 'raw', ''));
$documento = $this->storeRawEmailDocumento($ticket, $mailbox, $subject, $from, $to, $date, $rawEml, $gmailId, $openingUserId);
$ticket->messages()->create([
'user_id' => $openingUserId,
'messaggio' => Str::limit($bodyText !== '' ? $bodyText : ($subject !== '' ? $subject : 'Email importata da Gmail'), 4000),
'canale' => 'email',
'direzione' => 'inbound',
'oggetto' => $subject !== '' ? $subject : null,
'email_mittente' => $from !== '' ? $from : null,
'email_destinatario' => $to !== '' ? $to : null,
'external_message_id' => $externalId,
'inviato_il' => $this->normalizeDate($date),
'eml_documento_id' => $documento->id,
'metadata' => [
'gmail_message_id' => $gmailId,
'gmail_thread_id' => Arr::get($message, 'threadId'),
'gmail_mailbox_email' => $mailbox['email'] ?? null,
'google_account_key' => $mailbox['google_account_key'] ?? null,
],
]);
CommunicationMessage::query()->create([
'channel' => 'email',
'direction' => 'inbound',
'external_message_id' => $externalId,
'stabile_id' => (int) $stabile->id,
'sender_name' => $from !== '' ? $from : null,
'message_text' => Str::limit($bodyText !== '' ? $bodyText : ($subject !== '' ? $subject : 'Email importata da Gmail'), 4000),
'ticket_id' => (int) $ticket->id,
'status' => 'linked_to_ticket',
'received_at' => $this->normalizeDate($date),
'metadata' => [
'gmail_message_id' => $gmailId,
'gmail_thread_id' => Arr::get($message, 'threadId'),
'gmail_mailbox_email' => $mailbox['email'] ?? null,
'google_account_key' => $mailbox['google_account_key'] ?? null,
'subject' => $subject,
'to' => $to,
'documento_id' => (int) $documento->id,
],
]);
$attachments += $this->storeAttachments($token, $gmailId, $ticket, (array) Arr::get($message, 'payload', []), $subject, $openingUserId);
$imported++;
}
return [
'mailbox' => (string) ($mailbox['label'] ?? $mailbox['email'] ?? 'casella-gmail'),
'imported' => $imported,
'skipped' => $skipped,
'attachments' => $attachments,
];
}
/**
* @return array<string, string>
*/
private function extractHeaders(array $headers): array
{
$result = [];
foreach ($headers as $header) {
$name = strtolower(trim((string) ($header['name'] ?? '')));
if ($name === '') {
continue;
}
$result[$name] = trim((string) ($header['value'] ?? ''));
}
return $result;
}
/**
* @param array<string, mixed> $payload
*/
private function extractBodyText(array $payload): string
{
$parts = [];
$this->collectTextParts($payload, $parts);
$plain = trim(implode("\n\n", array_filter($parts)));
if ($plain !== '') {
return Str::limit($plain, 3500);
}
return trim((string) Arr::get($payload, 'body.data', '')) !== ''
? Str::limit($this->decodeBase64Url((string) Arr::get($payload, 'body.data', '')), 3500)
: '';
}
/**
* @param array<string, mixed> $part
* @param array<int, string> $collector
*/
private function collectTextParts(array $part, array &$collector): void
{
$mimeType = strtolower(trim((string) ($part['mimeType'] ?? '')));
if ($mimeType === 'text/plain') {
$text = trim($this->decodeBase64Url((string) Arr::get($part, 'body.data', '')));
if ($text !== '') {
$collector[] = $text;
}
}
foreach ((array) ($part['parts'] ?? []) as $child) {
if (is_array($child)) {
$this->collectTextParts($child, $collector);
}
}
}
/**
* @param array<string, mixed> $payload
*/
private function storeAttachments(string $token, string $gmailId, Ticket $ticket, array $payload, string $subject, int $userId): int
{
$count = 0;
foreach ($this->flattenAttachmentParts($payload) as $part) {
$fileName = trim((string) ($part['filename'] ?? ''));
if ($fileName === '') {
continue;
}
$data = trim((string) Arr::get($part, 'body.data', ''));
if ($data === '') {
$attachmentId = trim((string) Arr::get($part, 'body.attachmentId', ''));
if ($attachmentId !== '') {
$response = Http::withToken($token)
->acceptJson()
->get('https://gmail.googleapis.com/gmail/v1/users/me/messages/' . rawurlencode($gmailId) . '/attachments/' . rawurlencode($attachmentId));
if ($response->successful()) {
$data = trim((string) Arr::get($response->json(), 'data', ''));
}
}
}
if ($data === '') {
continue;
}
$binary = $this->decodeBase64Url($data);
if ($binary === '') {
continue;
}
$stored = $this->ticketAttachmentUploadService->storeRawContents(
$binary,
'ticket-email/' . $ticket->id,
$fileName,
[
'mime' => (string) ($part['mimeType'] ?? ''),
'optimize_image' => false,
'display_name' => $fileName,
'stored_basename' => pathinfo($fileName, PATHINFO_FILENAME),
]
);
TicketAttachment::query()->create([
'ticket_id' => (int) $ticket->id,
'ticket_update_id' => null,
'user_id' => $userId,
'file_path' => $stored['path'],
'original_file_name' => $stored['original_name'],
'mime_type' => $stored['mime'],
'size' => $stored['size'],
'description' => Str::limit('Allegato email importata' . ($subject !== '' ? ': ' . $subject : ''), 255),
]);
$count++;
}
return $count;
}
/**
* @param array<string, mixed> $payload
* @return array<int, array<string, mixed>>
*/
private function flattenAttachmentParts(array $payload): array
{
$parts = [];
foreach ((array) ($payload['parts'] ?? []) as $part) {
if (! is_array($part)) {
continue;
}
if (trim((string) ($part['filename'] ?? '')) !== '') {
$parts[] = $part;
}
$parts = array_merge($parts, $this->flattenAttachmentParts($part));
}
return $parts;
}
private function alreadyImported(string $externalId, string $gmailId): bool
{
return CommunicationMessage::query()
->whereIn('external_message_id', array_values(array_filter([$externalId, $gmailId])))
->exists();
}
/**
* @param array<string, mixed> $mailbox
*/
private function buildTicketDescription(Stabile $stabile, array $mailbox, string $subject, string $from, string $to, mixed $date, string $bodyText): string
{
$lines = [
'Email importata automaticamente dalla casella stabile.',
'Stabile: ' . ($stabile->denominazione ?: ('#' . $stabile->id)),
];
$mailboxLabel = trim((string) ($mailbox['label'] ?? ''));
$mailboxEmail = trim((string) ($mailbox['email'] ?? ''));
if ($mailboxLabel !== '' || $mailboxEmail !== '') {
$lines[] = 'Casella: ' . trim($mailboxLabel . ($mailboxEmail !== '' ? ' <' . $mailboxEmail . '>' : ''));
}
if ($subject !== '') {
$lines[] = 'Oggetto: ' . $subject;
}
if ($from !== '') {
$lines[] = 'Mittente: ' . $from;
}
if ($to !== '') {
$lines[] = 'Destinatario: ' . $to;
}
if ($date) {
$lines[] = 'Data email: ' . (string) $date;
}
if ($bodyText !== '') {
$lines[] = '';
$lines[] = 'Corpo messaggio:';
$lines[] = $bodyText;
}
return trim(implode("\n", $lines));
}
private function storeRawEmailDocumento(Ticket $ticket, array $mailbox, string $subject, string $from, string $to, mixed $date, string $rawEml, string $gmailId, int $userId): Documento
{
$fileName = 'gmail-' . $gmailId . '.eml';
$path = 'documenti/ticket-email/' . $ticket->id . '/' . $fileName;
Storage::disk('public')->put($path, $rawEml);
return Documento::query()->create([
'documentable_id' => $ticket->id,
'documentable_type' => Ticket::class,
'stabile_id' => $ticket->stabile_id,
'utente_id' => $userId,
'nome_file' => $fileName,
'path_file' => $path,
'percorso_file' => $path,
'tipo_documento' => 'Email EML',
'tipologia' => 'comunicazione',
'nome' => $subject !== '' ? $subject : ('Email Gmail ticket #' . $ticket->id),
'mime_type' => 'message/rfc822',
'estensione' => 'eml',
'dimensione_file' => strlen($rawEml),
'data_documento' => $this->normalizeDate($date),
'data_upload' => now(),
'descrizione' => 'Email importata automaticamente da ' . trim((string) ($mailbox['email'] ?? 'Gmail')),
'tags' => 'email,ticket,gmail,pec',
'note' => 'Import automatico Gmail per ticket #' . $ticket->id . ' da ' . ($from !== '' ? $from : 'mittente non disponibile'),
]);
}
/**
* @return array<string, mixed>
*/
private function fetchMessage(string $token, string $gmailId, string $format): array
{
$response = Http::withToken($token)
->acceptJson()
->get('https://gmail.googleapis.com/gmail/v1/users/me/messages/' . rawurlencode($gmailId), [
'format' => $format,
]);
if (! $response->successful()) {
throw new RuntimeException('Impossibile leggere il messaggio Gmail ' . $gmailId . '.');
}
return (array) $response->json();
}
private function decodeBase64Url(string $value): string
{
$normalized = strtr($value, '-_', '+/');
$padding = strlen($normalized) % 4;
if ($padding > 0) {
$normalized .= str_repeat('=', 4 - $padding);
}
$decoded = base64_decode($normalized, true);
return is_string($decoded) ? $decoded : '';
}
private function normalizeDate(mixed $value): mixed
{
$raw = trim((string) $value);
if ($raw === '') {
return null;
}
try {
return Carbon::parse($raw);
} catch (\Throwable) {
return null;
}
}
}