v1.2.0
- E-mail intake (IMAP), optional and off by default: clients can create a ticket or reply to an existing one just by sending/replying to an e-mail. Configure any number of mailboxes in the new Admin > Poczta page (SMTP + IMAP together, replacing the old "E-MAIL" tab), each routed to a specific subcategory or a whole category (new tickets.category_id column). Replies are matched to their ticket via the number/checksum already in every notification subject; autoresponders/bounces are detected and rejected; "restrict tickets to LDAP" is enforced for e-mail like the guest web form. Manual "Pobierz teraz" per-mailbox fetch button; dedicated storage/logs/imap-*.log regardless of the app's log level; mail-icon badges on e-mail-originated tickets/messages in the operator queue and ticket view. - Operator queue: "select all" checkbox in the table header for every currently visible ticket under the active filter/tab. - Fixed: scheduled commands (SLA breach check, automation rules, and now IMAP fetch) always sent notifications through .env's default mailer instead of the configured SMTP server, because AppServiceProvider's Settings override used to skip itself for any console command, not just migrate. - Fixed: visiting a ticket that no longer exists (deleted mid-session, or a stale background refresh) showed a raw 404 instead of redirecting back to the operator queue / client dashboard. - Docs: README/ARCHITECTURE/CLAUDE/install/wiki updated for all of the above, including the previously-missing host crontab entry for schedule:run. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
282
src/app/Services/ImapMailboxFetcher.php
Normal file
282
src/app/Services/ImapMailboxFetcher.php
Normal file
@@ -0,0 +1,282 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ImapMailbox;
|
||||
use App\Support\Imap\InboundEmail;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
use Webklex\PHPIMAP\Client;
|
||||
use Webklex\PHPIMAP\ClientManager;
|
||||
use Webklex\PHPIMAP\Message;
|
||||
|
||||
/**
|
||||
* I/O layer for the "reply/create ticket by e-mail" feature — connects to
|
||||
* every enabled ImapMailbox, fetches unseen messages and delegates every
|
||||
* decision to ImapMessageClassifier (pure logic) + TicketService (the
|
||||
* existing ticket-mutation API). Kept thin and mostly untested directly;
|
||||
* ImapMessageClassifier carries the actual test coverage.
|
||||
*/
|
||||
class ImapMailboxFetcher
|
||||
{
|
||||
private const HEADER_FIELDS = ['auto-submitted', 'x-autoreply', 'x-autorespond', 'precedence'];
|
||||
|
||||
public function __construct(
|
||||
private readonly ImapMessageClassifier $classifier,
|
||||
private readonly TicketService $tickets,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{created: int, replied: int, rejected: int, errors: int}
|
||||
*/
|
||||
public function fetchAll(): array
|
||||
{
|
||||
$totals = ['created' => 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<int, array{filename: string, mime: string, content: string}>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user