0, 'replied' => 0, 'rejected' => 0, 'errors' => 0]; foreach (ImapMailbox::query()->where('enabled', true)->get() as $mailbox) { foreach ($this->fetchMailbox($mailbox) as $key => $value) { $totals[$key] += $value; } } return $totals; } /** * @return array{created: int, replied: int, rejected: int, errors: int} */ public function fetchMailbox(ImapMailbox $mailbox): array { $result = ['created' => 0, 'replied' => 0, 'rejected' => 0, 'errors' => 0]; $log = Log::channel('imap'); $log->info("[{$mailbox->name}] łączenie z {$mailbox->host}:{$mailbox->port} (folder: {$mailbox->folder})"); try { $client = $this->connect($mailbox); $folder = $client->getFolder($mailbox->folder ?: 'INBOX'); $messages = $folder->messages()->whereUnseen()->get(); $log->info("[{$mailbox->name}] {$messages->count()} nieprzeczytanych wiadomości"); foreach ($messages as $message) { try { $this->processMessage($mailbox, $message, $result, $log); } catch (Throwable $e) { $result['errors']++; $log->error("[{$mailbox->name}] błąd przetwarzania wiadomości (uid={$message->getUid()}) — {$e->getMessage()}"); } } $client->disconnect(); $mailbox->update(['last_checked_at' => now(), 'last_error' => null]); $log->info("[{$mailbox->name}] zakończono: {$result['created']} nowych, {$result['replied']} odpowiedzi, {$result['rejected']} odrzuconych, {$result['errors']} błędów"); } catch (Throwable $e) { $result['errors']++; $mailbox->update(['last_checked_at' => now(), 'last_error' => $e->getMessage()]); $log->error("[{$mailbox->name}] połączenie nieudane — {$e->getMessage()}"); } return $result; } /** * Opens a connection and lists the configured folder, without fetching * or touching any message — used by the admin "Testuj połączenie" button. * Returns null on success, the exception message on failure. */ public function testConnection(ImapMailbox $mailbox): ?string { try { $client = $this->connect($mailbox); $client->getFolder($mailbox->folder ?: 'INBOX'); $client->disconnect(); return null; } catch (Throwable $e) { return $e->getMessage(); } } private function connect(ImapMailbox $mailbox): Client { $manager = new ClientManager; $client = $manager->make([ 'host' => $mailbox->host, 'port' => $mailbox->port, 'protocol' => 'imap', 'encryption' => $mailbox->encryption === 'none' ? false : $mailbox->encryption, 'validate_cert' => $mailbox->validate_cert, 'username' => $mailbox->username, 'password' => $mailbox->password, ]); $client->connect(); return $client; } /** * @param array{created: int, replied: int, rejected: int, errors: int} $result */ private function processMessage(ImapMailbox $mailbox, Message $message, array &$result, LoggerInterface $log): void { $email = $this->toInboundEmail($message); $uid = $message->getUid(); $log->debug("[{$mailbox->name}] uid={$uid} od={$email->fromEmail} temat=\"{$email->subject}\" nagłówki=".json_encode($email->headers, JSON_UNESCAPED_UNICODE)); $rejectReason = $this->classifier->rejectionReason($email, $mailbox->blocklistedSenders()); if ($rejectReason === null && ! $this->classifier->isSenderAllowed($email->fromEmail)) { $rejectReason = "nadawca spoza LDAP ({$email->fromEmail}), a restrict_tickets_to_ldap jest włączone"; } if ($rejectReason !== null) { $this->finish($message, $mailbox->rejected_folder); $result['rejected']++; $log->info("[{$mailbox->name}] uid={$uid} ODRZUCONO od {$email->fromEmail} \"{$email->subject}\" — {$rejectReason}"); return; } // Oznacz/przenieś PRZED utworzeniem ticketu: awaria w tym miejscu // zostawia co najwyżej "przetworzoną" wiadomość bez ticketu (widoczne, // łatwe do naprawienia ręcznie) zamiast duplikatu ticketu przy // ponownym uruchomieniu. $this->finish($message, $mailbox->processed_folder); $ticket = $this->classifier->matchTicket($email->subject); $sender = $this->classifier->resolveSender($email->fromEmail); $attachments = $this->buildAttachments($email, $mailbox, $log); $authorName = $email->fromName !== '' ? $email->fromName : $email->fromEmail; if ($ticket) { if ($sender) { $this->tickets->clientReply($ticket, $sender, $email->body(), $attachments, source: 'email'); } else { $this->tickets->guestReply($ticket, $authorName, $email->body(), $attachments, source: 'email'); } $result['replied']++; $log->info("[{$mailbox->name}] uid={$uid} ODPOWIEDŹ od {$email->fromEmail} dopisana do zgłoszenia #{$ticket->id} ({$ticket->displayNumber()})"); return; } $newTicket = $this->tickets->create([ 'email' => $email->fromEmail, 'name' => $authorName, 'subcategory_id' => $mailbox->default_subcategory_id, 'category_id' => $mailbox->default_category_id, 'subject' => $email->subject !== '' ? $email->subject : '(bez tematu)', 'body' => $email->body(), 'source' => 'email', ], $sender, $authorName); $result['created']++; $log->info("[{$mailbox->name}] uid={$uid} NOWE zgłoszenie #{$newTicket->id} ({$newTicket->displayNumber()}) od {$email->fromEmail}"); } private function finish(Message $message, ?string $moveToFolder): void { try { $message->setFlag('Seen'); } catch (Throwable $e) { Log::channel('imap')->warning("IMAP: nie udało się oznaczyć wiadomości jako przeczytanej — {$e->getMessage()}"); } if ($moveToFolder) { $message->move($moveToFolder); } } private function toInboundEmail(Message $message): InboundEmail { $fromAddress = $message->getFrom()->first(); $header = $message->getHeader(); // Webklex's Header::get() returns an *empty* Attribute (not null) // for a header that isn't present at all, and Attribute::first() on // that empty instance comes back as '' rather than null — so a // plain "!== null" check on the resulting value is always true, // making every message look like it carries every one of these // headers. Only keep a header that actually has content. $headers = []; foreach (self::HEADER_FIELDS as $name) { $value = $header?->get($name)->first(); if ($value !== null && $value !== '') { $headers[$name] = (string) $value; } } return new InboundEmail( fromEmail: $fromAddress?->mail ?? '', fromName: $this->decodeHeaderText(trim((string) ($fromAddress?->personal ?? ''), '"')), subject: $this->decodeHeaderText((string) $message->getSubject()), textBody: (string) $message->getTextBody(), htmlBody: (string) $message->getHTMLBody(), headers: $headers, attachments: $this->extractAttachments($message), ); } /** * Some senders' mail clients leave the Subject/From display-name as raw * RFC 2047 encoded-words (e.g. "=?utf-8?Q?...?=") instead of the * decoded UTF-8 webklex's own config claims to produce — decode * defensively rather than showing garbled text on the ticket. */ private function decodeHeaderText(string $value): string { return $value !== '' ? mb_decode_mimeheader($value) : $value; } /** * @return array */ private function extractAttachments(Message $message): array { $attachments = []; foreach ($message->getAttachments() as $attachment) { $attachments[] = [ 'filename' => $attachment->getName() ?: 'attachment', 'mime' => $attachment->getMimeType() ?: 'application/octet-stream', 'content' => $attachment->getContent(), ]; } return $attachments; } /** * Converts raw attachment bytes into UploadedFile instances (via a temp * file + the $test=true flag, which lets Symfony's UploadedFile skip the * is_uploaded_file() check outside of a real HTTP request) so they flow * through TicketService::attachFiles() unchanged. Validated the same way * every other caller validates before calling attachFiles() — a mail * carrying an oversized/disallowed attachment still creates the * ticket/reply, just without that attachment, rather than being dropped * entirely or silently bypassing the admin's attachment policy. * * @return UploadedFile[] */ private function buildAttachments(InboundEmail $email, ImapMailbox $mailbox, LoggerInterface $log): array { $files = []; foreach ($email->attachments as $attachment) { $path = tempnam(sys_get_temp_dir(), 'imap_'); file_put_contents($path, $attachment['content']); $files[] = new UploadedFile($path, $attachment['filename'], $attachment['mime'], null, true); } if ($files && ($error = Settings::validateAttachments($files))) { $log->warning("[{$mailbox->name}] pominięto załączniki wiadomości od {$email->fromEmail} — {$error}"); return []; } return $files; } }