diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 93b0799..4ea6762 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -101,6 +101,18 @@ The `{numer}` placeholder available in admin-editable e-mail templates own `#{numer}`, so adding the prefix there too would double it up or clash with a non-default prefix. +A ticket route binding that resolves to nothing (most commonly: the ticket +was deleted while someone had it open, and a later request — typically +Livewire's own "model missing during hydration" recovery, which does a full +`window.location.reload()` of the same page — hits `{ticket}` again) no +longer surfaces Laravel's default 404 page. `bootstrap/app.php` registers a +`NotFoundHttpException` render callback (note: `Handler::prepareException()` +already converts `ModelNotFoundException` into `NotFoundHttpException`, +wrapped as `getPrevious()`, *before* any render callback runs — a callback +typed against `ModelNotFoundException` itself would never match) that +redirects to `operator.queue`/`client.dashboard` instead, for any +authenticated request under `operator/*`/`client/*`. + ## Roles & permissions `$user->roles` reads/writes as a plain array (`['client', 'operator']`), but @@ -138,7 +150,7 @@ attributes. over the `settings` table, with hardcoded defaults for every key (company name, LDAP/SMTP connection details, attachment limits, session lifetime, timezone, branding/email HTML, etc.). Admin > Konfiguracja (general/attachments/session), -E-MAIL (SMTP) and Integracje (LDAP, BookStack) all write to this same table, and +Poczta (SMTP) and Integracje (LDAP, BookStack) all write to this same table, and `AppServiceProvider::boot()` re-applies the relevant subset of it over `config()` on every request — meaning **`Setting` rows win over `.env`** for LDAP, mail, session lifetime and timezone once they're non-empty. This is by @@ -147,6 +159,19 @@ source of the "seeded placeholder overrides real `.env` values" gotcha documented in [install.md](install.md) — anything touching LDAP/mail/session/ timezone config should go through `Settings`, not raw `config()`/`.env` reads. +`settingsTableUsable()` gates all four overrides on whether the `settings` +table is safe to query yet — but is deliberately scoped to just the `migrate` +command family (`runningConsoleCommand('migrate', 'migrate:fresh', ...)`), not +"any console command". It used to blanket-skip for every console invocation +(exempting only unit tests), which silently broke every scheduled command's +outbound mail: `AppServiceProvider::boot()` runs on each process including +`schedule:run`-invoked commands, so `tickets:check-sla-breaches`, +`automation:run-rules` and `emails:fetch-imap` (below) all sent notifications +through whatever `.env`'s `MAIL_MAILER` happened to be (`log`, i.e. nowhere) +instead of the admin-configured SMTP server — with no error, since the `log` +mailer never throws. If a scheduled command's notification/lookup ever again +seems to silently use `.env` defaults instead of `Settings`, check here first. + ## Notifications `TicketService::notify(Ticket $ticket, string $triggerKey)` is the single @@ -276,6 +301,86 @@ that closes the ticket doesn't block earlier-ordered rules already applied this run, but a later rule's own query naturally excludes an already-closed ticket. +## IMAP e-mail intake + +Optional, off by default (`ImapMailbox.enabled` per row — there is no single +global toggle since this is a list of N mailboxes, not a `Settings` +singleton). Split across three layers, mirroring the plan that shipped it: + +- **`App\Models\ImapMailbox`** — one row per polled mailbox (host/port/ + encryption/username, `password` cast `'encrypted'` — the first model in + this codebase to use Laravel's native encrypted cast rather than the + manual `Crypt::` pattern `Settings` uses, since this is a list of records + rather than key/value config). `default_subcategory_id` XOR + `default_category_id` (enforced by the admin form's single combined + selector, not a DB constraint) route new tickets; `category_id` only ever + gets populated when there's no subcategory to derive one from (see + `Ticket::categoryLabel()`/`TicketService::create()`). +- **`App\Services\ImapMessageClassifier`** — pure decision logic, no IMAP + connection, fully Pest-testable: `rejectionReason()` (auto-reply/bounce + detection via `Auto-Submitted`/`Precedence`/`X-Autoreply` headers + EN/PL + subject phrases + a per-mailbox sender blocklist), `matchTicket()` + (extracts every digit run ≥4 chars from the subject — after stripping + `Re:`/`Odp:`/`Fwd:`/`FW:`/`Aw:` — and tries each through + `Ticket::resolveRouteBinding()`, so it transparently matches either the + plain sequential number or the obfuscated checksum, whichever mode is + active; no changes to outbound mail were needed since every notification + subject already carries `{numer}`), `isSenderAllowed()` (mirrors + `Landing::emailIsKnown()` — enforces `restrict_tickets_to_ldap` for e-mail + exactly like the guest web form), `resolveSender()` (existing local user, + or `LdapUserProvisioner::findOrCreateByEmail()` if enabled). +- **`App\Services\ImapMailboxFetcher`** — the I/O layer (`webklex/php-imap`, + a pure-PHP IMAP client with no `ext-imap` dependency — confirmed available + extensions were sufficient, no Dockerfile change needed). Fetches + `whereUnseen()` per mailbox, flags/moves a message **before** creating the + ticket (a crash mid-batch then risks a "processed but no ticket" message — + visible and easy to fix manually — rather than a duplicate ticket on the + next run), converts attachments to `UploadedFile` via a temp file (`$test + = true` bypasses the `is_uploaded_file()` check outside a real HTTP + request) so they flow through the existing `Settings::validateAttachments()` + + `TicketService::attachFiles()` unchanged. Logs every connection attempt + and per-message decision to a dedicated `imap` log channel + (`storage/logs/imap-*.log`, always `debug` level regardless of the app's + own `LOG_LEVEL` — see `config/logging.php`) since this app commonly runs + at `LOG_LEVEL=error`, which would otherwise silently swallow this + activity entirely. + - One real bug worth remembering if IMAP rejection logic ever seems too + aggressive again: Webklex's `Header::get($name)` returns an *empty* + `Attribute` (not `null`) for a header that isn't present at all, and + `Attribute::first()` on that empty instance is `''`, not `null` — a + naive `$header !== null` check therefore treats *every* message as + carrying *every* header. Guarded in two places: `ImapMailboxFetcher` + only keeps a header value that's non-empty, and + `InboundEmail::header()` itself also treats `''` as absent, so the bug + can't resurface even if some other header source stops filtering. +- **`TicketService::guestReply()`** — the one new method added to the + existing service: a customer reply with no `User` account (mirrors + `clientReply()` — real customer activity, resets SLA silence, fires + `comment_added` so an admin-configured Trigger can reopen a closed ticket + — rather than `apiMessage()`, which tags a system/integration note, not + client content). Both `clientReply()` and `guestReply()` take an optional + trailing `string $source = 'web'`, stored as `TicketMessage.source` + (`null` for `'web'`) — the per-message counterpart to `Ticket.source`, + since a ticket opened on the web can later get an e-mail reply or vice + versa. Both surface as a small mail-icon badge (operator queue: next to + the ticket number; ticket view: per-message in the thread, plus a tag next + to the ticket number in the header). +- **`emails:fetch-imap`** (`app/Console/Commands/FetchImapEmails.php`), + registered in `routes/console.php` as + `Schedule::command('emails:fetch-imap')->everyFiveMinutes()->withoutOverlapping()` + — the one scheduled command in this app that opts into + `withoutOverlapping()` (SLA/automation don't), given IMAP I/O latency. + Early-returns if no `ImapMailbox` is enabled. Also callable directly per + mailbox from Admin > Poczta's "Pobierz teraz" button + (`ImapMailboxFetcher::fetchMailbox()`, bypassing the enabled-only + `fetchAll()` used by the schedule) for on-demand fetching/diagnosis + without shell access. + +Requires the same external `schedule:run` cron entry as SLA/automation (see +[install.md](install.md) and the crontab note in +[CLAUDE.md](CLAUDE.md)) — without it, only the manual "Pobierz teraz" button +does anything. + ## API `routes/api.php` + `app/Http/Controllers/Api/` expose a small ability-scoped REST diff --git a/CHANGELOG.md b/CHANGELOG.md index 842bc34..fa25f5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,67 @@ All notable changes to this project are documented in this file. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [1.2.0] - 2026-07-23 + +### Added + +- **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 (which + now hosts SMTP alongside IMAP, replacing the old "E-MAIL" tab), each with + its own host/port/encryption/credentials/folder and routed to either a + specific subcategory (routes to that subcategory's team, same as a web + ticket) or a whole category with no subcategory (a new `tickets.category_id` + column covers this case — previously a ticket's category only ever came + through a subcategory). + - A reply is matched back to its ticket via the number/checksum already + present in every notification e-mail's subject — works with either the + plain sequential number or the obfuscated checksum, whichever numbering + mode is active, no changes to outbound templates needed. + - Automatic replies (autoresponders, "out of office", bounces/mailer-daemon) + are detected via headers and common EN/PL subject phrasing and rejected + instead of creating a ticket; a per-mailbox sender blocklist covers the + rest. The "tylko użytkownicy z LDAP" restriction is enforced for e-mail + exactly like the guest web form. + - A "Pobierz teraz" button per mailbox fetches immediately, outside the + 5-minute schedule — useful for testing a freshly-configured mailbox or + diagnosing why a specific e-mail didn't turn into a ticket. + - Every connection attempt and per-message decision (accepted/rejected/ + matched to which ticket) is logged to a dedicated `storage/logs/imap-*.log` + file, independent of the app's own log level. + - Tickets and individual messages that came in by e-mail show a small + mail-icon badge in the operator queue and ticket view, distinguishing them + from ones created/replied to on the web. +- **Operator queue**: a "select all" checkbox in the table header + selects/deselects every ticket currently visible under the active + filter/tab in one click, instead of clicking each row's checkbox. + +### Changed + +- Admin's old **"E-MAIL"** tab is now **"Poczta"** and also lists/manages the + IMAP mailboxes above — the two halves of "reply by e-mail" (send/receive) + now live together instead of SMTP being off on its own. + +### Fixed + +- **Scheduled-command notifications were silently going nowhere.** + `AppServiceProvider`'s Settings-based config override (SMTP/LDAP/session/ + timezone) used to skip itself for *any* console command, not just + `migrate` — meaning `tickets:check-sla-breaches` and `automation:run-rules` + (and now `emails:fetch-imap`) always sent their e-mails through whatever + `.env`'s `MAIL_MAILER` happened to be (`log`, i.e. nowhere) instead of the + admin-configured SMTP server, with no visible error. Now scoped to just the + `migrate` command family, so every scheduled command gets the same live + config a web request would. +- Visiting a ticket that no longer exists (most commonly: it was deleted + while the viewer had it open, and a later background refresh hit the same + URL) no longer shows Laravel's default 404 page — redirects back to the + operator queue or client dashboard instead. +- This host had no crontab entry at all for `php artisan schedule:run` — + meaning SLA breach checks and automation rules had never actually run on + their own, only ever on request. Documented and configured (see + [CLAUDE.md](CLAUDE.md)). + ## [1.1.4] - 2026-07-23 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 88ba348..58e2bc1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,6 +60,30 @@ with no rebuild or restart: view:clear` to flush any root-owned compiled views before ending the session — don't wait for a report of a broken page to catch it. +## Scheduled commands need a host crontab entry + +The Docker image ships no cron/supervisor of its own (see [install.md](install.md)), +so `tickets:check-sla-breaches`, `automation:run-rules`, and `emails:fetch-imap` +(all registered in `routes/console.php` via `Schedule::command(...)`) only ever +run if something outside the container calls `php artisan schedule:run` on a +timer. **As of 2026-07-23 this is configured** — root's crontab on the host +runs, every minute: + +```cron +* * * * * cd /mnt/rabbit-containers/servicedesk && docker compose exec -T servicedesk php artisan schedule:run >> /dev/null 2>&1 +``` + +(`sudo crontab -l -u root` to inspect/edit — it previously did not exist at all, +which meant none of the three scheduled commands above had ever run +automatically; ask before changing this again, since removing it silently +breaks SLA checks, automation rules and IMAP fetching, and confusingly not the +IMAP feature alone if you're only debugging that one.) IMAP-specific activity +(connect attempts, per-message accept/reject decisions, created/replied ticket +ids) is logged separately from the app's normal `LOG_LEVEL` to +`storage/logs/imap-*.log` (see the `imap` channel in `config/logging.php`) — +check there first when a mailbox isn't behaving as expected, before assuming +the scheduler itself isn't firing. + ## Apache `/icons/` alias trap The stock `php:apache` image enables `mods-enabled/alias.conf`, which defines diff --git a/README.md b/README.md index 0f80b09..e906c84 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,18 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides. - **Attachments** — drag-and-drop upload (in addition to the file picker); every attachment shows in the message thread as just its filename, opening in a new tab on click (no inline image preview). +- **E-mail intake (IMAP)** *(optional, off by default)* — clients can create + tickets or reply to an existing one just by sending/replying to an e-mail; + configure any number of mailboxes in Admin > Poczta (e.g. one address per + team), each routed to a specific subcategory or a whole category. A reply + is matched back to its ticket via the number/checksum already present in + every notification's subject; automatic replies (autoresponders, bounces) + are detected and rejected instead of creating junk tickets, and the + "restrict tickets to LDAP" setting is enforced for e-mail exactly like the + guest web form. A manual "Pobierz teraz" button fetches immediately + outside the 5-minute schedule; all activity is logged separately to + `storage/logs/imap-*.log`. Tickets/messages that came in by e-mail show a + small mail-icon badge in the operator queue and ticket view. - **Configurable ticket numbering** (Admin > Konfiguracja) — a custom prefix and minimum zero-padded length for the ticket number, plus an optional "hide ticket order" mode that displays a stable per-ticket checksum instead of the @@ -131,7 +143,8 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides. - **Backend**: Laravel, Livewire (server-driven UI, no SPA build beyond Tailwind/Vite for CSS), LdapRecord for directory auth, Sanctum for API tokens, L5-Swagger for API docs, Laravel Reverb for WebSocket broadcasting (real-time queue/chat - updates — see [ARCHITECTURE.md](ARCHITECTURE.md)). + updates — see [ARCHITECTURE.md](ARCHITECTURE.md)), webklex/php-imap for the + optional e-mail intake fetcher (pure-PHP IMAP client, no `ext-imap` needed). - **Frontend**: Blade + Livewire + a little Alpine.js for local UI state; Tailwind v4 via Vite for `resources/css/app.css`; Laravel Echo + Pusher-protocol client (`resources/js/echo.js`) for Reverb. No JS charting library — the statistics @@ -179,8 +192,9 @@ src/ Laravel application app/Livewire/ Client/Operator/Admin Livewire components app/Models/ Eloquent models app/Events/ Broadcast events (TicketQueueChanged, TicketMessagePosted) - app/Console/Commands/ Scheduled commands (SLA breach check, automation rules) - app/Services/ TicketService (ticket lifecycle + notifications), BookStackClient + app/Console/Commands/ Scheduled commands (SLA breach check, automation rules, IMAP fetch) + app/Services/ TicketService (ticket lifecycle + notifications), BookStackClient, + ImapMailboxFetcher (I/O) + ImapMessageClassifier (pure logic) app/Ldap/ LDAP user model + sync handlers database/migrations/ Schema (one file per table group, final shape) database/seeders/ DatabaseSeeder — reference data, no ticket data diff --git a/install.md b/install.md index 46d5c97..3a7fe61 100644 --- a/install.md +++ b/install.md @@ -265,11 +265,14 @@ docker run --rm -v "$(pwd)/src":/app -w /app node:22 npm run build Powtarzaj drugi krok po każdej zmianie w `resources/css/` lub `resources/js/`. -### 1.6. Zadanie cykliczne (SLA) i kolejka +### 1.6. Zadanie cykliczne (SLA, automatyzacje, poczta IMAP) i kolejka -`routes/console.php` planuje `tickets:check-sla-breaches` co 15 minut, ale **obraz -Dockera nie ma wbudowanego cron/supervisora** — bez dodatkowego kroku to zadanie -nigdy się nie uruchomi. Najprościej dodać wpis crona **na hoście**: +`routes/console.php` planuje `tickets:check-sla-breaches` i `automation:run-rules` +co 15 minut oraz `emails:fetch-imap` (odbieranie zgłoszeń/odpowiedzi e-mailem — +patrz Admin > Poczta) co 5 minut, ale **obraz Dockera nie ma wbudowanego +cron/supervisora** — bez dodatkowego kroku żadne z tych zadań nigdy się nie +uruchomi (poczta IMAP nadal da się sprawdzić ręcznie przyciskiem „Pobierz teraz”, +ale bez crona nic nie dzieje się samo). Najprościej dodać wpis crona **na hoście**: ```cron * * * * * cd /ścieżka/do/repo && docker compose exec -T servicedesk php artisan schedule:run >> /dev/null 2>&1 @@ -457,9 +460,10 @@ server { } ``` -### 2.6. Zadanie cykliczne (SLA) i kolejka +### 2.6. Zadanie cykliczne (SLA, automatyzacje, poczta IMAP) i kolejka -Crontab użytkownika, pod którym stoi aplikacja (np. `www-data`): +Crontab użytkownika, pod którym stoi aplikacja (np. `www-data`) — obsługuje też +`automation:run-rules` i `emails:fetch-imap` (patrz 1.6 wyżej): ```cron * * * * * cd /var/www/servicedesk/src && php artisan schedule:run >> /dev/null 2>&1 diff --git a/src/app/Console/Commands/FetchImapEmails.php b/src/app/Console/Commands/FetchImapEmails.php new file mode 100644 index 0000000..b3e4e27 --- /dev/null +++ b/src/app/Console/Commands/FetchImapEmails.php @@ -0,0 +1,33 @@ +where('enabled', true)->exists()) { + return self::SUCCESS; + } + + $totals = $fetcher->fetchAll(); + + $this->info(sprintf( + 'IMAP fetch: %d nowych, %d odpowiedzi, %d odrzuconych, %d błędów.', + $totals['created'], + $totals['replied'], + $totals['rejected'], + $totals['errors'], + )); + + return self::SUCCESS; + } +} diff --git a/src/app/Livewire/Admin/MailSettings.php b/src/app/Livewire/Admin/MailSettings.php new file mode 100644 index 0000000..9648595 --- /dev/null +++ b/src/app/Livewire/Admin/MailSettings.php @@ -0,0 +1,335 @@ + null, + 'name' => '', + 'enabled' => true, + 'host' => '', + 'port' => 993, + 'encryption' => 'ssl', + 'validateCert' => true, + 'username' => '', + 'password' => '', + 'folder' => 'INBOX', + 'processedFolder' => '', + 'rejectedFolder' => '', + 'target' => '', + 'blocklistSenders' => 'mailer-daemon,postmaster,no-reply,noreply', + ]; + + public ?int $mailboxTestResultId = null; + + public ?string $mailboxTestResult = null; + + public ?string $mailboxTestMessage = null; + + public ?int $mailboxFetchResultId = null; + + public ?string $mailboxFetchSummary = null; + + public function mount(): void + { + $this->mailConfig = [ + 'smtpEnabled' => Settings::bool('mail_smtp_enabled'), + 'smtpHost' => Settings::get('mail_smtp_host'), + 'smtpPort' => Settings::get('mail_smtp_port'), + 'smtpUsername' => Settings::get('mail_smtp_username'), + 'smtpPassword' => Settings::get('mail_smtp_password'), + 'smtpEncryption' => Settings::get('mail_smtp_encryption'), + 'fromAddress' => Settings::get('mail_from_address'), + 'fromName' => Settings::get('mail_from_name'), + ]; + } + + #[Computed] + public function mailboxes(): Collection + { + return ImapMailbox::query()->with(['defaultSubcategory.category', 'defaultCategory'])->orderBy('name')->get(); + } + + /** + * Categories with their subcategories nested, for the mailbox form's + * single combined "cała kategoria albo konkretna podkategoria" selector. + */ + #[Computed] + public function categoryOptions(): Collection + { + return Category::query()->with('subcategories')->orderBy('name')->get() + ->map(fn (Category $c) => [ + 'id' => $c->id, + 'name' => $c->name, + 'subcategories' => $c->subcategories->map(fn ($s) => ['id' => $s->id, 'name' => $s->name])->values(), + ]) + ->values(); + } + + // ===================== SMTP ===================== + + public function saveMailConfig(): void + { + Settings::set('mail_smtp_enabled', $this->mailConfig['smtpEnabled'] ? '1' : '0'); + Settings::set('mail_smtp_host', $this->mailConfig['smtpHost']); + Settings::set('mail_smtp_port', (string) $this->mailConfig['smtpPort']); + Settings::set('mail_smtp_username', $this->mailConfig['smtpUsername']); + + if ($this->mailConfig['smtpPassword']) { + Settings::set('mail_smtp_password', $this->mailConfig['smtpPassword']); + } + + Settings::set('mail_smtp_encryption', $this->mailConfig['smtpEncryption']); + Settings::set('mail_from_address', $this->mailConfig['fromAddress']); + Settings::set('mail_from_name', $this->mailConfig['fromName']); + + $this->mailTestResult = null; + } + + /** + * Sends a real test e-mail to the logged-in admin using the form's + * current (unsaved) values, temporarily overriding the mail config the + * same way AppServiceProvider does for real once saved. + */ + public function testMailConnection(): void + { + $cfg = $this->mailConfig; + + if (empty($cfg['smtpHost']) || empty($cfg['fromAddress'])) { + $this->mailTestResult = 'error'; + + return; + } + + $original = Config::get('mail'); + + try { + Config::set('mail.default', 'smtp'); + Config::set('mail.mailers.smtp.host', $cfg['smtpHost']); + Config::set('mail.mailers.smtp.port', (int) $cfg['smtpPort']); + Config::set('mail.mailers.smtp.username', $cfg['smtpUsername'] ?: null); + Config::set('mail.mailers.smtp.password', $cfg['smtpPassword'] ?: Settings::get('mail_smtp_password')); + Config::set('mail.mailers.smtp.scheme', match ($cfg['smtpEncryption']) { + 'ssl' => 'smtps', + 'tls' => 'smtp', + default => null, + }); + Config::set('mail.from.address', $cfg['fromAddress']); + Config::set('mail.from.name', $cfg['fromName'] ?: Settings::get('company_name')); + + app()->forgetInstance('mail.manager'); + app()->forgetInstance('mailer'); + + Mail::raw('To jest testowa wiadomość wysłana z panelu administratora Servicedesk.', function ($message) { + $message->to(Auth::user()->email)->subject('Test konfiguracji SMTP'); + }); + + $this->mailTestResult = 'ok'; + } catch (\Throwable) { + $this->mailTestResult = 'error'; + } finally { + Config::set('mail', $original); + app()->forgetInstance('mail.manager'); + app()->forgetInstance('mailer'); + } + } + + // ===================== IMAP MAILBOXES ===================== + + public function openMailboxForm(): void + { + $this->reset('mailboxForm'); + $this->mailboxForm = [ + 'id' => null, + 'name' => '', + 'enabled' => true, + 'host' => '', + 'port' => 993, + 'encryption' => 'ssl', + 'validateCert' => true, + 'username' => '', + 'password' => '', + 'folder' => 'INBOX', + 'processedFolder' => '', + 'rejectedFolder' => '', + 'target' => '', + 'blocklistSenders' => 'mailer-daemon,postmaster,no-reply,noreply', + ]; + $this->mailboxTestResultId = null; + $this->resetErrorBag(); + $this->mailboxFormOpen = true; + } + + public function editMailbox(int $id): void + { + $mailbox = ImapMailbox::query()->findOrFail($id); + + $target = match (true) { + (bool) $mailbox->default_subcategory_id => "subcategory:{$mailbox->default_subcategory_id}", + (bool) $mailbox->default_category_id => "category:{$mailbox->default_category_id}", + default => '', + }; + + $this->mailboxForm = [ + 'id' => $mailbox->id, + 'name' => $mailbox->name, + 'enabled' => $mailbox->enabled, + 'host' => $mailbox->host, + 'port' => $mailbox->port, + 'encryption' => $mailbox->encryption, + 'validateCert' => $mailbox->validate_cert, + 'username' => $mailbox->username, + 'password' => $mailbox->password, + 'folder' => $mailbox->folder, + 'processedFolder' => $mailbox->processed_folder, + 'rejectedFolder' => $mailbox->rejected_folder, + 'target' => $target, + 'blocklistSenders' => $mailbox->blocklist_senders, + ]; + $this->mailboxTestResultId = null; + $this->resetErrorBag(); + $this->mailboxFormOpen = true; + } + + public function closeMailboxForm(): void + { + $this->mailboxFormOpen = false; + } + + public function submitMailboxForm(): void + { + $this->validate([ + 'mailboxForm.name' => ['required', 'string', 'max:255'], + 'mailboxForm.host' => ['required', 'string', 'max:255'], + 'mailboxForm.port' => ['required', 'integer', 'min:1', 'max:65535'], + 'mailboxForm.encryption' => ['required', 'in:ssl,tls,none'], + 'mailboxForm.username' => ['required', 'string', 'max:255'], + 'mailboxForm.folder' => ['required', 'string', 'max:255'], + ]); + + [$targetType, $targetId] = str_contains((string) $this->mailboxForm['target'], ':') + ? explode(':', $this->mailboxForm['target'], 2) + : [null, null]; + + $data = [ + 'name' => $this->mailboxForm['name'], + 'enabled' => (bool) $this->mailboxForm['enabled'], + 'host' => $this->mailboxForm['host'], + 'port' => (int) $this->mailboxForm['port'], + 'encryption' => $this->mailboxForm['encryption'], + 'validate_cert' => (bool) $this->mailboxForm['validateCert'], + 'username' => $this->mailboxForm['username'], + 'folder' => $this->mailboxForm['folder'], + 'processed_folder' => $this->mailboxForm['processedFolder'] ?: null, + 'rejected_folder' => $this->mailboxForm['rejectedFolder'] ?: null, + // Exactly one of these (or neither) — never both — driven by the + // form's single "cała kategoria albo konkretna podkategoria" selector. + 'default_subcategory_id' => $targetType === 'subcategory' ? $targetId : null, + 'default_category_id' => $targetType === 'category' ? $targetId : null, + 'blocklist_senders' => $this->mailboxForm['blocklistSenders'], + ]; + + $mailbox = ImapMailbox::query()->find($this->mailboxForm['id']); + + if ($mailbox) { + if ($this->mailboxForm['password']) { + $data['password'] = $this->mailboxForm['password']; + } + $mailbox->update($data); + } else { + $data['password'] = $this->mailboxForm['password']; + ImapMailbox::query()->create($data); + } + + $this->mailboxFormOpen = false; + unset($this->mailboxes); + } + + public function toggleMailboxEnabled(int $id): void + { + $mailbox = ImapMailbox::query()->findOrFail($id); + $mailbox->update(['enabled' => ! $mailbox->enabled]); + unset($this->mailboxes); + } + + public function removeMailbox(int $id): void + { + ImapMailbox::query()->findOrFail($id)->delete(); + unset($this->mailboxes); + } + + /** + * Runs a real fetch against one mailbox right now, outside the 5-minute + * schedule — for checking a freshly-configured mailbox without waiting, + * and for diagnosing "why didn't my e-mail turn into a ticket" without + * needing shell access. Allowed even while the mailbox is disabled + * (fetchAll(), used by the scheduled command, is the one that respects + * the enabled flag — this is an explicit admin action). + */ + public function fetchMailboxNow(int $id): void + { + $mailbox = ImapMailbox::query()->findOrFail($id); + $result = app(ImapMailboxFetcher::class)->fetchMailbox($mailbox); + + $this->mailboxFetchResultId = $id; + $this->mailboxFetchSummary = "Nowe: {$result['created']}, odpowiedzi: {$result['replied']}, odrzucone: {$result['rejected']}, błędy: {$result['errors']}."; + unset($this->mailboxes); + } + + /** + * Tests the form's current (unsaved) values against a throwaway + * ImapMailbox instance — mirrors testMailConnection()'s "don't require a + * save first" behavior. Falls back to the stored password when editing + * an existing mailbox and the password field was left blank. + */ + public function testMailboxConnection(): void + { + $mailbox = new ImapMailbox([ + 'host' => $this->mailboxForm['host'], + 'port' => (int) $this->mailboxForm['port'], + 'encryption' => $this->mailboxForm['encryption'], + 'validate_cert' => (bool) $this->mailboxForm['validateCert'], + 'username' => $this->mailboxForm['username'], + 'folder' => $this->mailboxForm['folder'] ?: 'INBOX', + ]); + + $mailbox->password = $this->mailboxForm['password'] + ?: ($this->mailboxForm['id'] ? ImapMailbox::query()->find($this->mailboxForm['id'])?->password : null); + + $error = app(ImapMailboxFetcher::class)->testConnection($mailbox); + + $this->mailboxTestResultId = (int) ($this->mailboxForm['id'] ?? 0); + $this->mailboxTestResult = $error === null ? 'ok' : 'error'; + $this->mailboxTestMessage = $error; + } + + public function render() + { + return view('livewire.admin.mail-settings'); + } +} diff --git a/src/app/Livewire/Admin/Panel.php b/src/app/Livewire/Admin/Panel.php index dc03398..3157236 100644 --- a/src/app/Livewire/Admin/Panel.php +++ b/src/app/Livewire/Admin/Panel.php @@ -22,8 +22,6 @@ use App\Services\LdapUserProvisioner; use App\Support\Settings; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Config; -use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; use LdapRecord\Connection; use Livewire\Attributes\Computed; @@ -148,10 +146,6 @@ class Panel extends Component public ?string $ldapTestResult = null; - public array $mailConfig = []; - - public ?string $mailTestResult = null; - public array $bookstackConfig = []; public ?string $bookstackTestResult = null; @@ -201,17 +195,6 @@ class Panel extends Component 'restrictTicketsToLdap' => Settings::bool('restrict_tickets_to_ldap'), ]; - $this->mailConfig = [ - 'smtpEnabled' => Settings::bool('mail_smtp_enabled'), - 'smtpHost' => Settings::get('mail_smtp_host'), - 'smtpPort' => Settings::get('mail_smtp_port'), - 'smtpUsername' => Settings::get('mail_smtp_username'), - 'smtpPassword' => Settings::get('mail_smtp_password'), - 'smtpEncryption' => Settings::get('mail_smtp_encryption'), - 'fromAddress' => Settings::get('mail_from_address'), - 'fromName' => Settings::get('mail_from_name'), - ]; - $this->bookstackConfig = [ 'enabled' => Settings::bool('bookstack_enabled'), 'baseUrl' => Settings::get('bookstack_base_url'), @@ -1525,75 +1508,6 @@ class Panel extends Component $this->bookstackTestMessage = $result['message']; } - // ===================== MAIL / SMTP CONFIG ===================== - - public function saveMailConfig(): void - { - Settings::set('mail_smtp_enabled', $this->mailConfig['smtpEnabled'] ? '1' : '0'); - Settings::set('mail_smtp_host', $this->mailConfig['smtpHost']); - Settings::set('mail_smtp_port', (string) $this->mailConfig['smtpPort']); - Settings::set('mail_smtp_username', $this->mailConfig['smtpUsername']); - - if ($this->mailConfig['smtpPassword']) { - Settings::set('mail_smtp_password', $this->mailConfig['smtpPassword']); - } - - Settings::set('mail_smtp_encryption', $this->mailConfig['smtpEncryption']); - Settings::set('mail_from_address', $this->mailConfig['fromAddress']); - Settings::set('mail_from_name', $this->mailConfig['fromName']); - - $this->mailTestResult = null; - } - - /** - * Sends a real test e-mail to the logged-in admin using the form's - * current (unsaved) values, temporarily overriding the mail config the - * same way AppServiceProvider does for real once saved — so this test - * exercises the exact path production notifications will use. - */ - public function testMailConnection(): void - { - $cfg = $this->mailConfig; - - if (empty($cfg['smtpHost']) || empty($cfg['fromAddress'])) { - $this->mailTestResult = 'error'; - - return; - } - - $original = Config::get('mail'); - - try { - Config::set('mail.default', 'smtp'); - Config::set('mail.mailers.smtp.host', $cfg['smtpHost']); - Config::set('mail.mailers.smtp.port', (int) $cfg['smtpPort']); - Config::set('mail.mailers.smtp.username', $cfg['smtpUsername'] ?: null); - Config::set('mail.mailers.smtp.password', $cfg['smtpPassword'] ?: Settings::get('mail_smtp_password')); - Config::set('mail.mailers.smtp.scheme', match ($cfg['smtpEncryption']) { - 'ssl' => 'smtps', - 'tls' => 'smtp', - default => null, - }); - Config::set('mail.from.address', $cfg['fromAddress']); - Config::set('mail.from.name', $cfg['fromName'] ?: Settings::get('company_name')); - - app()->forgetInstance('mail.manager'); - app()->forgetInstance('mailer'); - - Mail::raw('To jest testowa wiadomość wysłana z panelu administratora Servicedesk.', function ($message) { - $message->to(Auth::user()->email)->subject('Test konfiguracji SMTP'); - }); - - $this->mailTestResult = 'ok'; - } catch (\Throwable) { - $this->mailTestResult = 'error'; - } finally { - Config::set('mail', $original); - app()->forgetInstance('mail.manager'); - app()->forgetInstance('mailer'); - } - } - // ===================== GENERIC DELETE CONFIRM ===================== public function requestDelete(string $type, mixed $id, string $message): void diff --git a/src/app/Livewire/Operator/Queue.php b/src/app/Livewire/Operator/Queue.php index 92ba532..66e2ac0 100644 --- a/src/app/Livewire/Operator/Queue.php +++ b/src/app/Livewire/Operator/Queue.php @@ -298,7 +298,13 @@ class Queue extends Component $query->where('priority_key', $this->filterPriority); } if ($this->filterCategory !== 'all') { - $query->whereHas('subcategory', fn ($q) => $q->where('category_id', $this->filterCategory)); + // A ticket carries a category either via its subcategory or, + // when routed to a whole category with no subcategory (e.g. an + // IMAP mailbox), directly on tickets.category_id. + $query->where(function ($q) { + $q->whereHas('subcategory', fn ($sq) => $sq->where('category_id', $this->filterCategory)) + ->orWhere('category_id', $this->filterCategory); + }); } if ($this->filterCustomerId) { $query->where('customer_id', $this->filterCustomerId); @@ -307,7 +313,7 @@ class Queue extends Component $query->search($this->search); } - $tickets = $query->with(['subcategory.category', 'assignee', 'priority', 'status', 'team'])->get(); + $tickets = $query->with(['subcategory.category', 'category', 'assignee', 'priority', 'status', 'team'])->get(); return $this->sortTickets($tickets); } @@ -425,6 +431,20 @@ class Queue extends Component } } + /** + * Selects every ticket currently visible under the active filters/queue + * (not every ticket in the system) — toggles off if all of them are + * already selected, matching the usual "header checkbox" convention. + */ + public function toggleSelectAll(): void + { + $visibleIds = $this->filteredTickets->pluck('id')->all(); + + $this->selectedIds = empty(array_diff($visibleIds, $this->selectedIds)) + ? array_values(array_diff($this->selectedIds, $visibleIds)) + : array_values(array_unique(array_merge($this->selectedIds, $visibleIds))); + } + public function mergeSelected(): void { $ids = $this->selectedIdsInScope(); diff --git a/src/app/Models/ImapMailbox.php b/src/app/Models/ImapMailbox.php new file mode 100644 index 0000000..e2104f1 --- /dev/null +++ b/src/app/Models/ImapMailbox.php @@ -0,0 +1,65 @@ + 'boolean', + 'validate_cert' => 'boolean', + 'password' => 'encrypted', + 'last_checked_at' => 'datetime', + ]; + } + + public function defaultSubcategory(): BelongsTo + { + return $this->belongsTo(Subcategory::class, 'default_subcategory_id'); + } + + /** + * Only meaningful when default_subcategory_id is null — a mailbox is + * routed to either a specific subcategory or a whole category, never + * both (enforced by the admin form's single combined selector). + */ + public function defaultCategory(): BelongsTo + { + return $this->belongsTo(Category::class, 'default_category_id'); + } + + public function blocklistedSenders(): array + { + return array_filter(array_map('trim', explode(',', (string) $this->blocklist_senders))); + } + + public function targetLabel(): string + { + if ($this->defaultSubcategory) { + return $this->defaultSubcategory->category->name.' / '.$this->defaultSubcategory->name; + } + + if ($this->defaultCategory) { + return 'Cała kategoria: '.$this->defaultCategory->name; + } + + return '—'; + } +} diff --git a/src/app/Models/Ticket.php b/src/app/Models/Ticket.php index 9532cf5..87a4d33 100644 --- a/src/app/Models/Ticket.php +++ b/src/app/Models/Ticket.php @@ -13,8 +13,8 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; #[Fillable([ - 'number', 'checksum', 'customer_id', 'email', 'name', 'subcategory_id', 'subject', 'body', - 'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id', + 'number', 'checksum', 'customer_id', 'email', 'name', 'subcategory_id', 'category_id', 'subject', 'body', + 'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id', 'source', 'sla_notified_at', 'last_customer_activity_at', 'time_spent_seconds', 'timer_started_at', 'created_at', 'updated_at', 'csat_rating', 'csat_comment', 'csat_rated_at', ])] @@ -72,6 +72,16 @@ class Ticket extends Model return $this->belongsTo(Subcategory::class); } + /** + * Only ever set when there's no subcategory to derive a category from + * (subcategory_id already implies one via Subcategory::category()) — see + * categoryLabel() and the migration that introduced this column. + */ + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } + public function watchers(): BelongsToMany { return $this->belongsToMany(User::class, 'ticket_watchers'); @@ -214,7 +224,7 @@ class Ticket extends Model public function categoryLabel(): string { - return $this->subcategory?->label() ?? ''; + return $this->subcategory?->label() ?? $this->category?->name ?? ''; } /** diff --git a/src/app/Models/TicketMessage.php b/src/app/Models/TicketMessage.php index 40f20e4..944f7fd 100644 --- a/src/app/Models/TicketMessage.php +++ b/src/app/Models/TicketMessage.php @@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOneThrough; -#[Fillable(['ticket_id', 'author_name', 'internal', 'body', 'edited', 'api_client_id', 'created_at', 'updated_at'])] +#[Fillable(['ticket_id', 'author_name', 'internal', 'body', 'edited', 'api_client_id', 'source', 'created_at', 'updated_at'])] class TicketMessage extends Model { protected function casts(): array diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index bc0c624..85598e2 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -85,13 +85,21 @@ class AppServiceProvider extends ServiceProvider } /** - * Avoid touching the DB during artisan commands that run before the - * `settings` table exists (e.g. `migrate` itself), or before it can be - * queried at all — shared by every settings-driven config override below. + * Avoid touching the DB during the specific artisan commands that run + * before the `settings` table exists or could be mid-schema-change (the + * migrate family) — shared by every settings-driven config override + * below. Deliberately scoped to just those commands rather than "any + * console command": scheduled commands (`schedule:run` → e.g. + * `emails:fetch-imap`, `tickets:check-sla-breaches`) also run in the + * console and need the real SMTP/LDAP/timezone overrides exactly like a + * web request does, or their notifications/lookups silently fall back + * to whatever's in `.env` (this was a real bug: scheduled-command + * notifications were always going out via the `.env` `log` mailer + * instead of the configured SMTP server). */ protected function settingsTableUsable(): bool { - if ($this->app->runningInConsole() && ! $this->app->runningUnitTests()) { + if ($this->app->runningConsoleCommand('migrate', 'migrate:fresh', 'migrate:refresh', 'migrate:reset', 'migrate:rollback', 'migrate:install')) { return false; } diff --git a/src/app/Services/ImapMailboxFetcher.php b/src/app/Services/ImapMailboxFetcher.php new file mode 100644 index 0000000..35d92bf --- /dev/null +++ b/src/app/Services/ImapMailboxFetcher.php @@ -0,0 +1,282 @@ + 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; + } +} diff --git a/src/app/Services/ImapMessageClassifier.php b/src/app/Services/ImapMessageClassifier.php new file mode 100644 index 0000000..b529e3f --- /dev/null +++ b/src/app/Services/ImapMessageClassifier.php @@ -0,0 +1,137 @@ +header('auto-submitted')); + if ($autoSubmitted !== '' && $autoSubmitted !== 'no') { + return "Auto-Submitted: {$autoSubmitted}"; + } + + if ($email->header('x-autoreply') !== null || $email->header('x-autorespond') !== null) { + return 'X-Autoreply/X-Autorespond header present'; + } + + $precedence = strtolower((string) $email->header('precedence')); + if (in_array($precedence, ['bulk', 'junk', 'list'], true)) { + return "Precedence: {$precedence}"; + } + + $senderLocalPart = strtolower(explode('@', $email->fromEmail)[0] ?? ''); + $blocked = array_map('strtolower', $extraBlocklist); + if ($senderLocalPart !== '' && in_array($senderLocalPart, $blocked, true)) { + return "Blocked sender: {$email->fromEmail}"; + } + if (in_array(strtolower($email->fromEmail), $blocked, true)) { + return "Blocked sender: {$email->fromEmail}"; + } + + foreach (self::AUTO_REPLY_SUBJECT_PATTERNS as $pattern) { + if (preg_match($pattern, $email->subject) === 1) { + return "Subject matched auto-reply pattern ({$pattern})"; + } + } + + return null; + } + + /** + * Same gate Landing::submit() applies to web/guest ticket creation + * (Settings::bool('restrict_tickets_to_ldap')) — must apply identically + * to mail-originated tickets/replies, or the restriction has a hole. + */ + public function isSenderAllowed(string $email): bool + { + if (! Settings::bool('restrict_tickets_to_ldap')) { + return true; + } + + return User::query()->where('email', $email)->exists() + || app(LdapUserProvisioner::class)->existsInLdap($email); + } + + /** + * Existing local user, or an LDAP-provisioned one if enabled — mirrors + * TicketService::create()'s own guest-resolution branch. Returns null + * for a genuine, unprovisionable guest. + */ + public function resolveSender(string $email): ?User + { + if ($user = User::query()->where('email', $email)->first()) { + return $user; + } + + if (Settings::bool('ldap_auto_provision_guests')) { + return app(LdapUserProvisioner::class)->findOrCreateByEmail($email); + } + + return null; + } + + /** + * Strips common reply/forward prefixes, then tries every digit run of + * length >= 4 (longest first) against Ticket::resolveRouteBinding() — + * covers both the plain sequential number and the obfuscated checksum, + * since both are plain digit strings and every outbound notification + * subject already carries one (see database/seeders/DatabaseSeeder.php). + * Prefix-aware matching was considered and rejected: {numer} email + * templates hardcode their own literal '#', independent of the + * admin-configurable ticket_number_prefix setting, and templates are + * themselves admin-editable. + */ + public function matchTicket(string $subject): ?Ticket + { + $cleaned = preg_replace('/^\s*(re|odp|fwd|fw|aw)\s*:\s*/i', '', $subject) ?? $subject; + $cleaned = preg_replace('/^\s*(re|odp|fwd|fw|aw)\s*:\s*/i', '', $cleaned) ?? $cleaned; + + preg_match_all('/\d{4,}/', $cleaned, $matches); + $tokens = $matches[0] ?? []; + usort($tokens, fn ($a, $b) => strlen($b) <=> strlen($a)); + + foreach ($tokens as $token) { + $ticket = (new Ticket)->resolveRouteBinding($token); + if ($ticket) { + return $ticket; + } + } + + return null; + } +} diff --git a/src/app/Services/TicketService.php b/src/app/Services/TicketService.php index e349e80..97eab45 100644 --- a/src/app/Services/TicketService.php +++ b/src/app/Services/TicketService.php @@ -42,6 +42,10 @@ class TicketService 'email' => $customer?->email ?? $data['email'], 'name' => $customer?->name ?? ($data['name'] ?? $data['email']), 'subcategory_id' => $subcategory?->id, + // category_id only ever carries a value when there's no + // subcategory to derive one from (e.g. an IMAP mailbox routed to + // a whole category rather than a specific subcategory). + 'category_id' => $subcategory ? null : ($data['category_id'] ?? null), 'subject' => $data['subject'], 'body' => $data['body'], 'status_key' => Settings::get('default_status', 'new'), @@ -50,6 +54,7 @@ class TicketService 'assignee_id' => $data['assignee_id'] ?? null, 'custom_fields' => $data['custom_values'] ?? [], 'last_customer_activity_at' => now(), + 'source' => $data['source'] ?? 'web', ]); $message = $ticket->messages()->create([ @@ -213,11 +218,12 @@ class TicketService TicketMessagePosted::dispatch($ticket->id, $message->id, true, $operator->id); } - public function clientReply(Ticket $ticket, User $client, string $body, array $attachments = []): void + public function clientReply(Ticket $ticket, User $client, string $body, array $attachments = [], string $source = 'web'): void { $message = $ticket->messages()->create([ 'author_name' => $client->name, 'body' => $body, + 'source' => $source === 'web' ? null : $source, ]); $message->attachAuthor($client->id, 'client'); $ticket->touch(); @@ -238,6 +244,36 @@ class TicketService TicketQueueChanged::dispatch($ticket->id, 'message_posted', $client->id); } + /** + * A reply from a customer with no User account — e.g. an e-mail reply + * from an address the IMAP fetcher couldn't resolve to a local/LDAP + * user. Mirrors clientReply() (real customer activity: resets SLA + * silence, fires comment_added so an admin-configured Trigger can reopen + * a closed ticket) rather than apiMessage() (attachAuthor(null, null) — + * a system/integration note, not client content). attachAuthor(null, + * 'client') matches how create() already tags a guest's opening message. + */ + public function guestReply(Ticket $ticket, string $authorName, string $body, array $attachments = [], string $source = 'web'): TicketMessage + { + $message = $ticket->messages()->create([ + 'author_name' => $authorName, + 'body' => $body, + 'source' => $source === 'web' ? null : $source, + ]); + $message->attachAuthor(null, 'client'); + $ticket->touch(); + $this->attachFiles($ticket, $message, $attachments); + + $ticket->update(['last_customer_activity_at' => now()]); + $ticket->automationRuleLogs()->delete(); + + app(TriggerEngine::class)->handle($ticket, 'comment_added'); + TicketMessagePosted::dispatch($ticket->id, $message->id, false, null); + TicketQueueChanged::dispatch($ticket->id, 'message_posted', null); + + return $message; + } + public function toggleWatch(Ticket $ticket, User $user): bool { if ($ticket->isWatchedBy($user)) { diff --git a/src/app/Support/Imap/InboundEmail.php b/src/app/Support/Imap/InboundEmail.php new file mode 100644 index 0000000..b6e2509 --- /dev/null +++ b/src/app/Support/Imap/InboundEmail.php @@ -0,0 +1,49 @@ + $headers lower-cased header names + * @param array $attachments + */ + public function __construct( + public readonly string $fromEmail, + public readonly string $fromName, + public readonly string $subject, + public readonly string $textBody, + public readonly string $htmlBody, + public readonly array $headers, + public readonly array $attachments = [], + ) {} + + /** + * Treats an empty string the same as an absent header — the IMAP + * library backing ImapMailboxFetcher represents "header not present" as + * an empty value rather than a missing array key in some cases, so + * callers checking `header($x) !== null` alone would otherwise + * misdetect every message as carrying every header. + */ + public function header(string $name): ?string + { + $value = $this->headers[strtolower($name)] ?? null; + + return $value !== null && $value !== '' ? $value : null; + } + + public function body(): string + { + if (trim($this->textBody) !== '') { + return $this->textBody; + } + + return trim(html_entity_decode(strip_tags($this->htmlBody))); + } +} diff --git a/src/bootstrap/app.php b/src/bootstrap/app.php index aeb4ab8..f5017ab 100644 --- a/src/bootstrap/app.php +++ b/src/bootstrap/app.php @@ -1,12 +1,14 @@ withRouting( @@ -44,4 +46,29 @@ return Application::configure(basePath: dirname(__DIR__)) $exceptions->shouldRenderJsonWhen( fn (Request $request) => $request->is('api/*'), ); + + // A ticket deleted mid-session (typically by the operator/client + // currently viewing it) leaves any later request for that same + // {ticket} route binding 404ing — most commonly Livewire's own + // "model missing during hydration" recovery, which does a full + // window.location.reload() of the very page whose ticket just + // disappeared (e.g. the ticket-show view's periodic fallback + // refresh polling a few seconds after a delete+redirect). Land back + // on that area's own list page instead of a raw 404. + // + // Handler::prepareException() already converts ModelNotFoundException + // into NotFoundHttpException (wrapping the original as getPrevious()) + // before any render() callback is dispatched — a callback typed + // against ModelNotFoundException itself would simply never match. + $exceptions->render(function (NotFoundHttpException $e, Request $request) { + if (! $e->getPrevious() instanceof ModelNotFoundException || ! $request->user()) { + return null; + } + + return match (true) { + $request->is('operator/*') => redirect()->route('operator.queue'), + $request->is('client/*') => redirect()->route('client.dashboard'), + default => null, + }; + }); })->create(); diff --git a/src/composer.json b/src/composer.json index 791935a..9ddf899 100644 --- a/src/composer.json +++ b/src/composer.json @@ -16,7 +16,8 @@ "laravel/reverb": "*", "laravel/sanctum": "*", "laravel/tinker": "^3.0", - "livewire/livewire": "*" + "livewire/livewire": "*", + "webklex/php-imap": "*" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/src/composer.lock b/src/composer.lock index 3cdbe9b..7f76565 100644 --- a/src/composer.lock +++ b/src/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "321add40614eb8751e0c8dbda55016eb", + "content-hash": "abe8bd31e8d8849ae593e562f73a39df", "packages": [ { "name": "brick/math", @@ -7593,6 +7593,87 @@ ], "time": "2026-04-26T05:33:54+00:00" }, + { + "name": "webklex/php-imap", + "version": "6.2.0", + "source": { + "type": "git", + "url": "https://github.com/Webklex/php-imap.git", + "reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Webklex/php-imap/zipball/6b8ef85d621bbbaf52741b00cca8e9237e2b2e05", + "reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "ext-iconv": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-zip": "*", + "illuminate/pagination": ">=5.0.0", + "nesbot/carbon": "^2.62.1|^3.2.4", + "php": "^8.0.2", + "symfony/http-foundation": ">=2.8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5.10" + }, + "suggest": { + "symfony/mime": "Recomended for better extension support", + "symfony/var-dumper": "Usefull tool for debugging" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webklex\\PHPIMAP\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Malte Goldenbaum", + "email": "github@webklex.com", + "role": "Developer" + } + ], + "description": "PHP IMAP client", + "homepage": "https://github.com/webklex/php-imap", + "keywords": [ + "imap", + "mail", + "php-imap", + "pop3", + "webklex" + ], + "support": { + "issues": "https://github.com/Webklex/php-imap/issues", + "source": "https://github.com/Webklex/php-imap/tree/6.2.0" + }, + "funding": [ + { + "url": "https://www.buymeacoffee.com/webklex", + "type": "custom" + }, + { + "url": "https://ko-fi.com/webklex", + "type": "ko_fi" + } + ], + "time": "2025-04-25T06:02:37+00:00" + }, { "name": "zircote/swagger-php", "version": "6.4.0", diff --git a/src/config/logging.php b/src/config/logging.php index b09cb25..5feefc9 100644 --- a/src/config/logging.php +++ b/src/config/logging.php @@ -73,6 +73,19 @@ return [ 'replace_placeholders' => true, ], + // Dedicated, always-verbose channel for the IMAP fetcher + // (emails:fetch-imap) — kept separate from 'single'/LOG_LEVEL so a + // production app typically running at LOG_LEVEL=error still gets + // full visibility into what the fetcher did on every run, without + // that verbosity going into the main laravel.log. + 'imap' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/imap.log'), + 'level' => 'debug', + 'days' => 14, + 'replace_placeholders' => true, + ], + 'slack' => [ 'driver' => 'slack', 'url' => env('LOG_SLACK_WEBHOOK_URL'), diff --git a/src/database/migrations/2026_07_23_000153_create_imap_mailboxes_table.php b/src/database/migrations/2026_07_23_000153_create_imap_mailboxes_table.php new file mode 100644 index 0000000..23e5ede --- /dev/null +++ b/src/database/migrations/2026_07_23_000153_create_imap_mailboxes_table.php @@ -0,0 +1,36 @@ +id(); + $table->string('name'); + $table->boolean('enabled')->default(false); + $table->string('host'); + $table->unsignedSmallInteger('port')->default(993); + $table->string('encryption')->default('ssl'); + $table->boolean('validate_cert')->default(true); + $table->string('username'); + $table->text('password')->nullable(); + $table->string('folder')->default('INBOX'); + $table->string('processed_folder')->nullable(); + $table->string('rejected_folder')->nullable(); + $table->foreignId('default_subcategory_id')->nullable()->constrained('subcategories')->nullOnDelete(); + $table->string('blocklist_senders')->default('mailer-daemon,postmaster,no-reply,noreply'); + $table->timestamp('last_checked_at')->nullable(); + $table->text('last_error')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('imap_mailboxes'); + } +}; diff --git a/src/database/migrations/2026_07_23_000154_add_category_and_source_to_tickets.php b/src/database/migrations/2026_07_23_000154_add_category_and_source_to_tickets.php new file mode 100644 index 0000000..51f6884 --- /dev/null +++ b/src/database/migrations/2026_07_23_000154_add_category_and_source_to_tickets.php @@ -0,0 +1,42 @@ +foreignId('category_id')->nullable()->after('subcategory_id')->constrained('categories')->nullOnDelete(); + $table->string('source')->default('web')->after('api_client_id'); + }); + + Schema::table('imap_mailboxes', function (Blueprint $table) { + $table->foreignId('default_category_id')->nullable()->after('default_subcategory_id')->constrained('categories')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('imap_mailboxes', function (Blueprint $table) { + $table->dropConstrainedForeignId('default_category_id'); + }); + + Schema::table('tickets', function (Blueprint $table) { + $table->dropConstrainedForeignId('category_id'); + $table->dropColumn('source'); + }); + } +}; diff --git a/src/database/migrations/2026_07_23_000155_add_source_to_ticket_messages.php b/src/database/migrations/2026_07_23_000155_add_source_to_ticket_messages.php new file mode 100644 index 0000000..4c73147 --- /dev/null +++ b/src/database/migrations/2026_07_23_000155_add_source_to_ticket_messages.php @@ -0,0 +1,29 @@ +string('source')->nullable()->after('api_client_id'); + }); + } + + public function down(): void + { + Schema::table('ticket_messages', function (Blueprint $table) { + $table->dropColumn('source'); + }); + } +}; diff --git a/src/resources/views/livewire/admin/mail-settings.blade.php b/src/resources/views/livewire/admin/mail-settings.blade.php new file mode 100644 index 0000000..d88bd30 --- /dev/null +++ b/src/resources/views/livewire/admin/mail-settings.blade.php @@ -0,0 +1,182 @@ +
+

E-mail (SMTP)

+
+
+
+ +
+ + + Bez włączenia aplikacja wysyła pocztę zgodnie z konfiguracją środowiska (.env). + + @if ($mailConfig['smtpEnabled']) +
+
+
+
+ + +
+
+
+
+ +
+ + + @if ($mailTestResult === 'ok') +
check_circleWysłano na Twój adres
+ @elseif ($mailTestResult === 'error') +
errorBłąd wysyłki
+ @endif +
+ @else + + @endif +
+ +
+

Skrzynki IMAP (zgłoszenia i odpowiedzi przez e-mail)

+ +
+ +

+ Każda skrzynka jest sprawdzana co kilka minut — nowa wiadomość zakłada zgłoszenie w wybranej podkategorii (np. zgloszenia-it@firma.pl → IT), a odpowiedź na powiadomienie e-mail (temat zawiera numer zgłoszenia) trafia jako odpowiedź do istniejącego zgłoszenia. Automatyczne odpowiedzi (autorespondery, „poza biurem”, bounce) są odrzucane. +

+ + @if ($this->mailboxes->isNotEmpty()) +
+ + + + + + + + + + + + + @foreach ($this->mailboxes as $mailbox) + + + + + + + + + @endforeach + +
NazwaSerwerUżytkownikKategoria / podkategoriaStatus
{{ $mailbox->name }}{{ $mailbox->host }}:{{ $mailbox->port }}{{ $mailbox->username }}{{ $mailbox->targetLabel() }} + + @if ($mailbox->last_error) +
{{ $mailbox->last_error }}
+ @elseif ($mailbox->last_checked_at) +
Sprawdzono: {{ $mailbox->last_checked_at->format('Y-m-d H:i') }}
+ @endif + @if ($mailboxFetchResultId === $mailbox->id) +
{{ $mailboxFetchSummary }}
+ @endif +
+
+ + + +
+
+
+ @else +

Brak skonfigurowanych skrzynek IMAP. Dodaj pierwszą używając przycisku wyżej.

+ @endif + + @if ($mailboxFormOpen) +
+
+
{{ $mailboxForm['id'] ? 'Edytuj skrzynkę IMAP' : 'Nowa skrzynka IMAP' }}
+ +
+ + +
+ @error('mailboxForm.name') {{ $message }} @enderror + + + +
+ +
+ @error('mailboxForm.host') {{ $message }} @enderror + +
+
+
+ + +
+
+ + + +
+ @error('mailboxForm.username') {{ $message }} @enderror +
+ +
+ +
+ + + Wybierz konkretną podkategorię (trafi też do jej zespołu) albo całą kategorię, jeśli nie chcesz przypisywać konkretnej podkategorii. +
+ +
+
+
+
+
+ +
+ + +
+ +
+ + @if ($mailboxTestResult === 'ok') +
check_circlePołączono
+ @elseif ($mailboxTestResult === 'error') +
error{{ $mailboxTestMessage }}
+ @endif +
+ +
+ + +
+
+
+ @endif +
diff --git a/src/resources/views/livewire/admin/panel.blade.php b/src/resources/views/livewire/admin/panel.blade.php index de212bd..39916da 100644 --- a/src/resources/views/livewire/admin/panel.blade.php +++ b/src/resources/views/livewire/admin/panel.blade.php @@ -17,7 +17,7 @@ $tabGroups = [ ], 'Ustawienia' => [ ['key' => 'templates', 'label' => 'Szablony e-mail', 'icon' => 'mail'], - ['key' => 'email', 'label' => 'E-MAIL', 'icon' => 'forward_to_inbox'], + ['key' => 'email', 'label' => 'Poczta', 'icon' => 'forward_to_inbox'], ['key' => 'branding', 'label' => 'Wygląd i branding', 'icon' => 'palette'], ['key' => 'config', 'label' => 'Konfiguracja', 'icon' => 'settings'], ['key' => 'integrations', 'label' => 'Integracje', 'icon' => 'hub'], @@ -483,45 +483,7 @@ $tabGroups = [ -

E-mail (SMTP)

-
-
-
- -
- - - Bez włączenia aplikacja wysyła pocztę zgodnie z konfiguracją środowiska (.env). - - @if ($mailConfig['smtpEnabled']) -
-
-
-
- - -
-
-
-
- -
- - - @if ($mailTestResult === 'ok') -
check_circleWysłano na Twój adres
- @elseif ($mailTestResult === 'error') -
errorBłąd wysyłki
- @endif -
- @else - - @endif -
+ @endif {{-- ================= BRANDING ================= --}} diff --git a/src/resources/views/livewire/operator/queue.blade.php b/src/resources/views/livewire/operator/queue.blade.php index 6b96e84..7a7eeb1 100644 --- a/src/resources/views/livewire/operator/queue.blade.php +++ b/src/resources/views/livewire/operator/queue.blade.php @@ -137,7 +137,7 @@ - + @foreach ($columnDefs as $key => $label) @continue(! in_array($key, $visibleColumns)) @if (in_array('number', $visibleColumns)) - + @endif @if (in_array('subject', $visibleColumns)) diff --git a/src/resources/views/livewire/operator/ticket-show.blade.php b/src/resources/views/livewire/operator/ticket-show.blade.php index d7466ec..6f0be71 100644 --- a/src/resources/views/livewire/operator/ticket-show.blade.php +++ b/src/resources/views/livewire/operator/ticket-show.blade.php @@ -36,7 +36,14 @@
-
Zgłoszenie {{ $ticket->displayNumber() }}
+
+
Zgłoszenie {{ $ticket->displayNumber() }}
+ @if ($ticket->source === 'email') + + mailE-mail + + @endif +
@unless ($editingDetails) @endunless @@ -160,7 +167,12 @@
-
{{ $m->author_name }} · {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}
+
+ @if ($m->source === 'email') + mail + @endif + {{ $m->author_name }} · {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }} +
@if ($m->role === 'operator')
edit diff --git a/src/routes/console.php b/src/routes/console.php index 0ccaae6..6d1cd61 100644 --- a/src/routes/console.php +++ b/src/routes/console.php @@ -10,3 +10,4 @@ Artisan::command('inspire', function () { Schedule::command('tickets:check-sla-breaches')->everyFifteenMinutes(); Schedule::command('automation:run-rules')->everyFifteenMinutes(); +Schedule::command('emails:fetch-imap')->everyFiveMinutes()->withoutOverlapping(); diff --git a/src/tests/Feature/DeletedTicketRedirectsInsteadOf404Test.php b/src/tests/Feature/DeletedTicketRedirectsInsteadOf404Test.php new file mode 100644 index 0000000..c42f081 --- /dev/null +++ b/src/tests/Feature/DeletedTicketRedirectsInsteadOf404Test.php @@ -0,0 +1,31 @@ +create(['name' => 'Op', 'email' => 'op@example.com', 'roles' => ['operator']]); + $ticket = makeTicket(); + $id = $ticket->id; + $ticket->delete(); + + $this->actingAs($operator) + ->get("/operator/tickets/{$id}") + ->assertRedirect(route('operator.queue')); +}); + +test('visiting a deleted ticket as a client redirects to the client dashboard instead of 404ing', function () { + seedStatusesAndPriorities(); + $client = User::query()->create(['name' => 'Klient', 'email' => 'klient@example.com', 'roles' => ['client']]); + $ticket = makeTicket(['customer_id' => $client->id]); + $id = $ticket->id; + $ticket->delete(); + + $this->actingAs($client) + ->get("/client/tickets/{$id}") + ->assertRedirect(route('client.dashboard')); +}); + +test('a guest hitting a non-existent ticket route still gets the normal (non-redirected) handling', function () { + $this->get('/operator/tickets/999999')->assertRedirect(route('login')); +}); diff --git a/src/tests/Feature/ImapCategoryRoutingAndSourceBadgeTest.php b/src/tests/Feature/ImapCategoryRoutingAndSourceBadgeTest.php new file mode 100644 index 0000000..a752c0d --- /dev/null +++ b/src/tests/Feature/ImapCategoryRoutingAndSourceBadgeTest.php @@ -0,0 +1,203 @@ +create(['name' => 'Delegacje']); + + $ticket = app(TicketService::class)->create([ + 'email' => 'gosc@example.com', + 'category_id' => $category->id, + 'subject' => 'Sprawa delegacji', + 'body' => 'Treść', + ], null); + + expect($ticket->category_id)->toBe($category->id) + ->and($ticket->subcategory_id)->toBeNull() + ->and($ticket->categoryLabel())->toBe('Delegacje'); +}); + +test('create() leaves category_id null when a subcategory is given (category is derived from it)', function () { + seedStatusesAndPriorities(); + $subcategory = subcategoryFixture(); + + $ticket = app(TicketService::class)->create([ + 'email' => 'gosc@example.com', + 'subcategory_id' => $subcategory->id, + 'subject' => 'Sprawa VPN', + 'body' => 'Treść', + ], null); + + expect($ticket->category_id)->toBeNull() + ->and($ticket->categoryLabel())->toBe('IT / VPN'); +}); + +test('create() defaults source to web, and accepts an explicit source', function () { + seedStatusesAndPriorities(); + + $webTicket = app(TicketService::class)->create([ + 'email' => 'a@example.com', 'subject' => 'S', 'body' => 'B', + ], null); + + $mailTicket = app(TicketService::class)->create([ + 'email' => 'b@example.com', 'subject' => 'S', 'body' => 'B', 'source' => 'email', + ], null); + + expect($webTicket->source)->toBe('web') + ->and($mailTicket->source)->toBe('email'); +}); + +// ===================== ImapMailbox::targetLabel() ===================== + +test('targetLabel reflects subcategory, whole-category, or neither', function () { + $subcategory = subcategoryFixture(); + $category = Category::query()->create(['name' => 'Delegacje']); + + $bySubcategory = ImapMailbox::query()->create(mailboxFixtureData(['default_subcategory_id' => $subcategory->id])); + $byCategory = ImapMailbox::query()->create(mailboxFixtureData(['default_category_id' => $category->id])); + $unrouted = ImapMailbox::query()->create(mailboxFixtureData()); + + expect($bySubcategory->targetLabel())->toBe('IT / VPN') + ->and($byCategory->targetLabel())->toBe('Cała kategoria: Delegacje') + ->and($unrouted->targetLabel())->toBe('—'); +}); + +function mailboxFixtureData(array $overrides = []): array +{ + return array_merge([ + 'name' => 'Test', + 'enabled' => true, + 'host' => 'imap.example.com', + 'port' => 993, + 'encryption' => 'ssl', + 'validate_cert' => true, + 'username' => 'test@example.com', + 'password' => 'secret', + 'folder' => 'INBOX', + ], $overrides); +} + +// ===================== Admin: MailSettings mailbox form ===================== + +test('admin can route a mailbox to a whole category via the combined selector', function () { + $admin = adminUser(); + $category = Category::query()->create(['name' => 'Delegacje']); + + Livewire::actingAs($admin)->test(MailSettings::class) + ->call('openMailboxForm') + ->set('mailboxForm.name', 'Zgłoszenia delegacji') + ->set('mailboxForm.host', 'imap.example.com') + ->set('mailboxForm.username', 'zgloszenia-delegacje@example.com') + ->set('mailboxForm.password', 'secret') + ->set('mailboxForm.target', "category:{$category->id}") + ->call('submitMailboxForm') + ->assertOk(); + + $mailbox = ImapMailbox::query()->where('name', 'Zgłoszenia delegacji')->firstOrFail(); + + expect($mailbox->default_category_id)->toBe($category->id) + ->and($mailbox->default_subcategory_id)->toBeNull(); +}); + +test('admin can route a mailbox to a specific subcategory via the combined selector', function () { + $admin = adminUser(); + $subcategory = subcategoryFixture(); + + Livewire::actingAs($admin)->test(MailSettings::class) + ->call('openMailboxForm') + ->set('mailboxForm.name', 'Zgłoszenia IT') + ->set('mailboxForm.host', 'imap.example.com') + ->set('mailboxForm.username', 'zgloszenia-it@example.com') + ->set('mailboxForm.password', 'secret') + ->set('mailboxForm.target', "subcategory:{$subcategory->id}") + ->call('submitMailboxForm') + ->assertOk(); + + $mailbox = ImapMailbox::query()->where('name', 'Zgłoszenia IT')->firstOrFail(); + + expect($mailbox->default_subcategory_id)->toBe($subcategory->id) + ->and($mailbox->default_category_id)->toBeNull(); +}); + +test('switching an existing mailbox from a subcategory to a whole category clears the old target', function () { + $admin = adminUser(); + $subcategory = subcategoryFixture(); + $category = Category::query()->create(['name' => 'Delegacje']); + + $mailbox = ImapMailbox::query()->create(mailboxFixtureData(['default_subcategory_id' => $subcategory->id])); + + Livewire::actingAs($admin)->test(MailSettings::class) + ->call('editMailbox', $mailbox->id) + ->assertSet('mailboxForm.target', "subcategory:{$subcategory->id}") + ->set('mailboxForm.target', "category:{$category->id}") + ->call('submitMailboxForm') + ->assertOk(); + + $mailbox->refresh(); + + expect($mailbox->default_category_id)->toBe($category->id) + ->and($mailbox->default_subcategory_id)->toBeNull(); +}); + +// ===================== Operator UI: e-mail source badge ===================== + +test('the operator queue shows a mail icon next to an e-mail-originated ticket but not a web one', function () { + seedStatusesAndPriorities(); + $operator = operatorUser(); + $webTicket = makeTicket(['number' => '2001', 'source' => 'web']); + $mailTicket = makeTicket(['number' => '2002', 'source' => 'email']); + + Livewire::actingAs($operator)->test(Queue::class) + ->assertSeeHtml('title="Utworzone przez e-mail"'); +}); + +test('the ticket detail header shows an e-mail badge only for e-mail-originated tickets', function () { + seedStatusesAndPriorities(); + $operator = operatorUser(); + $mailTicket = makeTicket(['number' => '2003', 'source' => 'email']); + $webTicket = makeTicket(['number' => '2004', 'source' => 'web']); + + Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $mailTicket]) + ->assertSee('E-mail'); + + Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $webTicket]) + ->assertDontSee('E-mail'); +}); + +// ===================== Operator queue: category-only tickets are filterable ===================== + +test('filtering the queue by category includes a ticket routed to that whole category with no subcategory', function () { + seedStatusesAndPriorities(); + $operator = operatorUser(); + $category = Category::query()->create(['name' => 'Delegacje']); + $ticket = makeTicket(['number' => '2005', 'category_id' => $category->id]); + + Livewire::actingAs($operator)->test(Queue::class) + ->set('filterCategory', $category->id) + ->assertSee($ticket->displayNumber()); +}); + +// ===================== Operator UI: per-message e-mail source badge ===================== + +test('a reply fetched by e-mail shows a mail badge in the thread, a normal client reply does not', function () { + seedStatusesAndPriorities(); + $operator = operatorUser(); + $client = User::query()->create(['name' => 'Klient', 'email' => 'klient@example.com', 'roles' => ['client']]); + $ticket = makeTicket(['number' => '2006']); + + app(TicketService::class)->clientReply($ticket, $client, 'Odpowiedź z portalu.'); + app(TicketService::class)->clientReply($ticket, $client, 'Odpowiedź e-mailem.', source: 'email'); + + Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket]) + ->assertSeeHtml('title="Odebrane e-mailem"'); +}); diff --git a/src/tests/Feature/ImapMessageClassifierTest.php b/src/tests/Feature/ImapMessageClassifierTest.php new file mode 100644 index 0000000..33ae3d4 --- /dev/null +++ b/src/tests/Feature/ImapMessageClassifierTest.php @@ -0,0 +1,215 @@ +rejectionReason(makeInboundEmail()))->toBeNull(); +}); + +test('Auto-Submitted header other than "no" is rejected', function () { + $classifier = new ImapMessageClassifier; + + $email = makeInboundEmail(['headers' => ['auto-submitted' => 'auto-replied']]); + + expect($classifier->rejectionReason($email))->not->toBeNull(); +}); + +test('Auto-Submitted: no is not rejected', function () { + $classifier = new ImapMessageClassifier; + + $email = makeInboundEmail(['headers' => ['auto-submitted' => 'no']]); + + expect($classifier->rejectionReason($email))->toBeNull(); +}); + +test('X-Autoreply header is rejected', function () { + $classifier = new ImapMessageClassifier; + + $email = makeInboundEmail(['headers' => ['x-autoreply' => '1']]); + + expect($classifier->rejectionReason($email))->not->toBeNull(); +}); + +test('regression: an empty-string header value (present key, no content) is treated as absent, not rejected', function () { + // Reproduces the real production bug: Webklex's Header::get() returns + // an empty (non-null) Attribute for a header that isn't on the message + // at all, so a naive "!== null" check on x-autoreply/x-autorespond + // rejected every single inbound e-mail. + $classifier = new ImapMessageClassifier; + + $email = makeInboundEmail(['headers' => [ + 'auto-submitted' => '', 'x-autoreply' => '', 'x-autorespond' => '', 'precedence' => '', + ]]); + + expect($classifier->rejectionReason($email))->toBeNull(); +}); + +test('Precedence: bulk is rejected', function () { + $classifier = new ImapMessageClassifier; + + $email = makeInboundEmail(['headers' => ['precedence' => 'bulk']]); + + expect($classifier->rejectionReason($email))->not->toBeNull(); +}); + +test('a blocklisted sender is rejected', function () { + $classifier = new ImapMessageClassifier; + + $email = makeInboundEmail(['fromEmail' => 'mailer-daemon@example.com']); + + expect($classifier->rejectionReason($email, ['mailer-daemon']))->not->toBeNull(); +}); + +test('an out-of-office subject is rejected', function () { + $classifier = new ImapMessageClassifier; + + $email = makeInboundEmail(['subject' => 'Automatic reply: Out of Office']); + + expect($classifier->rejectionReason($email))->not->toBeNull(); +}); + +test('a Polish autoresponder subject is rejected', function () { + $classifier = new ImapMessageClassifier; + + $email = makeInboundEmail(['subject' => 'Automatyczna odpowiedz: nieobecnosc w biurze']); + + expect($classifier->rejectionReason($email))->not->toBeNull(); +}); + +// ===================== matchTicket() ===================== + +test('matches an existing ticket by its plain sequential number in the subject', function () { + seedStatusesAndPriorities(); + $ticket = makeTicket(['number' => '1042']); + + $classifier = new ImapMessageClassifier; + + expect($classifier->matchTicket('Re: Aktualizacja zgłoszenia #1042')->id)->toBe($ticket->id); +}); + +test('matches an existing ticket by its checksum when obfuscation is enabled', function () { + seedStatusesAndPriorities(); + $ticket = makeTicket(['number' => '1042']); + Settings::set('ticket_number_obfuscate', '1'); + + $classifier = new ImapMessageClassifier; + + expect($classifier->matchTicket("Re: Aktualizacja zgłoszenia #{$ticket->checksum}")->id)->toBe($ticket->id); +}); + +test('returns null when no digit run in the subject matches any ticket', function () { + seedStatusesAndPriorities(); + makeTicket(['number' => '1042']); + + $classifier = new ImapMessageClassifier; + + expect($classifier->matchTicket('Nowa sprawa bez numeru'))->toBeNull(); +}); + +test('strips common reply/forward prefixes before matching', function () { + seedStatusesAndPriorities(); + $ticket = makeTicket(['number' => '1042']); + + $classifier = new ImapMessageClassifier; + + foreach (['Re:', 'RE:', 'Odp:', 'Fwd:', 'FW:', 'Aw:'] as $prefix) { + expect($classifier->matchTicket("{$prefix} Zgłoszenie #1042")->id)->toBe($ticket->id); + } +}); + +test('when the subject has multiple digit runs, the one that actually resolves wins', function () { + seedStatusesAndPriorities(); + $ticket = makeTicket(['number' => '1042']); + + $classifier = new ImapMessageClassifier; + + // "2026" (a year, 4 digits) doesn't resolve to any ticket; "1042" does. + expect($classifier->matchTicket('Zgłoszenie #1042 z dnia 2026-07-23')->id)->toBe($ticket->id); +}); + +// ===================== isSenderAllowed() / resolveSender() ===================== + +test('isSenderAllowed allows any e-mail when restrict_tickets_to_ldap is off (the default)', function () { + $classifier = new ImapMessageClassifier; + + expect($classifier->isSenderAllowed('ktokolwiek@example.com'))->toBeTrue(); +}); + +test('isSenderAllowed rejects an unknown e-mail when restrict_tickets_to_ldap is on', function () { + DirectoryEmulator::setup(); + Settings::set('restrict_tickets_to_ldap', '1'); + + $classifier = new ImapMessageClassifier; + + expect($classifier->isSenderAllowed('nieznany@firma.pl'))->toBeFalse(); +}); + +test('isSenderAllowed allows an e-mail that exists in LDAP when restrict_tickets_to_ldap is on', function () { + DirectoryEmulator::setup(); + Settings::set('restrict_tickets_to_ldap', '1'); + + LldapUser::create([ + 'uid' => 'znany.gosc', + 'cn' => 'Znany Gość', + 'mail' => 'znany.gosc@firma.pl', + 'entryuuid' => (string) Str::uuid(), + ]); + + $classifier = new ImapMessageClassifier; + + expect($classifier->isSenderAllowed('znany.gosc@firma.pl'))->toBeTrue(); +}); + +test('isSenderAllowed allows an already-known local account even when restrict_tickets_to_ldap is on', function () { + DirectoryEmulator::setup(); + Settings::set('restrict_tickets_to_ldap', '1'); + + User::query()->create(['name' => 'Istniejący Klient', 'email' => 'istniejacy@firma.pl', 'roles' => ['client']]); + + $classifier = new ImapMessageClassifier; + + expect($classifier->isSenderAllowed('istniejacy@firma.pl'))->toBeTrue(); +}); + +test('resolveSender returns an existing local user without touching LDAP', function () { + $user = User::query()->create(['name' => 'Istniejący', 'email' => 'istniejacy@firma.pl', 'roles' => ['client']]); + + $classifier = new ImapMessageClassifier; + + expect($classifier->resolveSender('istniejacy@firma.pl')->id)->toBe($user->id); +}); + +test('resolveSender returns null for an unprovisionable guest', function () { + Settings::set('ldap_auto_provision_guests', '0'); + + $classifier = new ImapMessageClassifier; + + expect($classifier->resolveSender('nikt@example.com'))->toBeNull(); +}); diff --git a/src/tests/Feature/MailSmtpConfigTest.php b/src/tests/Feature/MailSmtpConfigTest.php index 965fc5a..0c7cb84 100644 --- a/src/tests/Feature/MailSmtpConfigTest.php +++ b/src/tests/Feature/MailSmtpConfigTest.php @@ -1,18 +1,18 @@ test(Panel::class) - ->call('setTab', 'email') + Livewire::actingAs($admin)->test(MailSettings::class) ->set('mailConfig.fromAddress', 'wsparcie@firma.pl') ->set('mailConfig.fromName', 'Zespół Wsparcia') ->set('mailConfig.smtpEnabled', true) @@ -31,7 +31,7 @@ test('admin can save the SMTP/from settings, and the password is only overwritte ->and(Settings::get('mail_smtp_password'))->toBe('sekret123'); // Saving again with a blank password field must not wipe the stored one. - Livewire::actingAs($admin)->test(Panel::class) + Livewire::actingAs($admin)->test(MailSettings::class) ->set('mailConfig.smtpHost', 'smtp.firma.pl') ->set('mailConfig.smtpPassword', '') ->call('saveMailConfig') @@ -44,14 +44,14 @@ test('the SMTP test button reports an error without a host/from address, and suc Mail::fake(); $admin = adminUser(); - Livewire::actingAs($admin)->test(Panel::class) + Livewire::actingAs($admin)->test(MailSettings::class) ->call('testMailConnection') ->assertSet('mailTestResult', 'error'); // Mail::fake()'s raw() is a no-op that never throws, so a valid config // reports success — this exercises the same config-override/restore path // real sends use, without needing a reachable SMTP server in tests. - Livewire::actingAs($admin)->test(Panel::class) + Livewire::actingAs($admin)->test(MailSettings::class) ->set('mailConfig.fromAddress', 'wsparcie@firma.pl') ->set('mailConfig.smtpHost', 'smtp.firma.pl') ->call('testMailConnection') @@ -109,3 +109,30 @@ test('AppServiceProvider always applies the from-address override regardless of expect(config('mail.from.address'))->toBe('wsparcie@firma.pl') ->and(config('mail.from.name'))->toBe('Wsparcie'); }); + +test('regression: the mail override still applies for a console command other than migrate (e.g. schedule:run/tinker)', function () { + // Reproduces the real production bug: settingsTableUsable() used to + // blanket-skip for *any* console command, which meant scheduled + // commands (emails:fetch-imap, tickets:check-sla-breaches) always sent + // mail via the .env "log" mailer instead of the configured SMTP server, + // since AppServiceProvider::boot() runs on every process including + // console ones. Only the migrate family should still be excluded. + $originalArgv = $_SERVER['argv'] ?? null; + + Settings::set('mail_smtp_enabled', '1'); + Settings::set('mail_smtp_host', 'smtp.enabled.example'); + + try { + $_SERVER['argv'] = ['artisan', 'emails:fetch-imap']; + (new AppServiceProvider(app()))->boot(); + expect(config('mail.default'))->toBe('smtp'); + + Config::set('mail.default', 'log'); + + $_SERVER['argv'] = ['artisan', 'migrate']; + (new AppServiceProvider(app()))->boot(); + expect(config('mail.default'))->not->toBe('smtp'); + } finally { + $_SERVER['argv'] = $originalArgv; + } +}); diff --git a/src/tests/Feature/OperatorQueueSelectAllTest.php b/src/tests/Feature/OperatorQueueSelectAllTest.php new file mode 100644 index 0000000..6464dce --- /dev/null +++ b/src/tests/Feature/OperatorQueueSelectAllTest.php @@ -0,0 +1,39 @@ + '1001']); + $b = makeTicket(['number' => '1002']); + + Livewire::actingAs($operator)->test(Queue::class) + ->call('toggleSelectAll') + ->assertSet('selectedIds', [$a->id, $b->id]); +}); + +test('toggleSelectAll deselects everything when all visible tickets are already selected', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('select-all-2@example.com'); + makeTicket(['number' => '1001']); + makeTicket(['number' => '1002']); + + Livewire::actingAs($operator)->test(Queue::class) + ->call('toggleSelectAll') + ->call('toggleSelectAll') + ->assertSet('selectedIds', []); +}); + +test('toggleSelectAll only affects tickets visible under the active filter, not every ticket', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('select-all-3@example.com'); + makeTicket(['number' => '1001', 'status_key' => 'open']); + $closed = makeTicket(['number' => '1002', 'status_key' => 'closed']); + + Livewire::actingAs($operator)->test(Queue::class) + ->set('queue', 'closed') + ->call('toggleSelectAll') + ->assertSet('selectedIds', [$closed->id]); +}); diff --git a/src/tests/Feature/TicketNumberObfuscationTest.php b/src/tests/Feature/TicketNumberObfuscationTest.php index 44b8934..bdb5f07 100644 --- a/src/tests/Feature/TicketNumberObfuscationTest.php +++ b/src/tests/Feature/TicketNumberObfuscationTest.php @@ -45,7 +45,12 @@ test('with obfuscation on, the ticket URL and the displayed number both use the ->and($ticket->displayNumber())->toBe('#'.$ticket->checksum); $this->actingAs($operator)->get($url)->assertOk(); - $this->actingAs($operator)->get('/operator/tickets/1042')->assertNotFound(); + + // A "no ticket matches this identifier" route-binding failure now + // redirects to the area's own queue instead of a bare 404 (see + // bootstrap/app.php) — the raw sequential number still doesn't resolve + // to the ticket, it just no longer surfaces as a dead-end error page. + $this->actingAs($operator)->get('/operator/tickets/1042')->assertRedirect(route('operator.queue')); }); test('the API still binds tickets by numeric id regardless of the obfuscation setting', function () { diff --git a/src/tests/Feature/TicketServiceGuestReplyTest.php b/src/tests/Feature/TicketServiceGuestReplyTest.php new file mode 100644 index 0000000..495f6e8 --- /dev/null +++ b/src/tests/Feature/TicketServiceGuestReplyTest.php @@ -0,0 +1,61 @@ +guestReply($ticket, 'Anonimowy Gość', 'Odpowiedź gościa e-mailem.'); + + expect($message->author_name)->toBe('Anonimowy Gość') + ->and($message->body)->toBe('Odpowiedź gościa e-mailem.') + ->and($message->author_id)->toBeNull() + ->and($message->role)->toBe('client') + ->and($message->source)->toBeNull(); +}); + +test('guestReply tags the message source as email when told to', function () { + seedStatusesAndPriorities(); + $ticket = makeTicket(); + + $message = app(TicketService::class)->guestReply($ticket, 'Gość', 'Treść', source: 'email'); + + expect($message->source)->toBe('email'); +}); + +test('clientReply defaults to a null (web) source, and can be tagged as email', function () { + seedStatusesAndPriorities(); + $ticket = makeTicket(); + $client = User::query()->create(['name' => 'Klient', 'email' => 'klient@example.com', 'roles' => ['client']]); + + app(TicketService::class)->clientReply($ticket, $client, 'Odpowiedź z portalu.'); + $webMessage = $ticket->messages()->latest('id')->first(); + + app(TicketService::class)->clientReply($ticket, $client, 'Odpowiedź e-mailem.', source: 'email'); + $emailMessage = $ticket->messages()->latest('id')->first(); + + expect($webMessage->source)->toBeNull() + ->and($emailMessage->source)->toBe('email'); +}); + +test('guestReply updates last_customer_activity_at and clears automation logs, like clientReply', function () { + seedStatusesAndPriorities(); + $ticket = makeTicket(['last_customer_activity_at' => now()->subDays(1)]); + + $rule = AutomationRule::query()->create([ + 'label' => 'Test rule', + 'condition_minutes' => 60, + 'action_type' => 'set_priority', + 'action_value' => 'high', + 'enabled' => true, + ]); + $ticket->automationRuleLogs()->create(['automation_rule_id' => $rule->id, 'triggered_at' => now()]); + + app(TicketService::class)->guestReply($ticket, 'Gość', 'Treść'); + + expect($ticket->fresh()->last_customer_activity_at->diffInSeconds(now()))->toBeLessThan(5) + ->and($ticket->automationRuleLogs()->count())->toBe(0); +}); diff --git a/wiki/admin/README.md b/wiki/admin/README.md index be3fcbd..2dcdc9f 100644 --- a/wiki/admin/README.md +++ b/wiki/admin/README.md @@ -3,7 +3,8 @@ Panel administratora (`/admin`) to jedno miejsce do konfiguracji całego systemu: struktura zgłoszeń (kategorie, pola, statusy, priorytety, SLA), automatyzacje (reguły SLA, wyzwalacze), użytkownicy i zespoły, treści (szablony, szybkie -akcje, e-maile), wygląd/branding oraz integracje (LDAP, SMTP, BookStack, API). +akcje, e-maile), wygląd/branding oraz integracje (LDAP, poczta SMTP/IMAP, +BookStack, API). Domyślnie każde konto ląduje po zalogowaniu w panelu Klienta; przełącz się do panelu Administratora przez menu profilu (prawy górny róg). @@ -174,8 +175,58 @@ ważne + treść HTML). ustawienia. SMTP (host, port, szyfrowanie, użytkownik/hasło, adres/nazwa nadawcy, z -przyciskiem **„Testuj połączenie”**) konfiguruje się w zakładce **E-MAIL**, -razem z layoutem/stopką wiadomości — patrz sekcja wyżej. +przyciskiem **„Testuj połączenie”**) konfiguruje się w zakładce **Poczta**, +razem z layoutem/stopką wiadomości i skrzynkami IMAP (patrz niżej). + +## Poczta — odbieranie zgłoszeń i odpowiedzi e-mailem (IMAP) + +Zakładka **Poczta** (dawniej „E-MAIL") łączy konfigurację SMTP (wysyłka) ze +skrzynkami IMAP (odbiór) — obie strony wymiany e-mailowej z klientem żyją +razem, zamiast być rozrzucone po różnych zakładkach. + +- **Wiele skrzynek IMAP jednocześnie** — np. `zgloszenia-it@firma.pl` i + `zgloszenia-hr@firma.pl` jako dwie osobne, niezależnie włączane skrzynki, + każda z własnym hostem/portem/szyfrowaniem/loginem/hasłem i folderem. +- **Cel nowych zgłoszeń** — jeden wspólny selektor pozwala wybrać albo + **konkretną podkategorię** (trafi też do jej zespołu, tak jak zgłoszenie + założone przez formularz web), albo **całą kategorię** bez wskazywania + podkategorii (zgłoszenie zostaje nieprzypisane do zespołu, ale kategoria + jest widoczna i można po niej filtrować kolejkę operatora), albo zostawić + puste (zgłoszenie całkiem nieprzypisane). +- **Dopasowywanie odpowiedzi** — odpowiedź na powiadomienie e-mail (temat + zawiera numer/sumę kontrolną zgłoszenia) trafia jako kolejna wiadomość do + tego samego wątku, nie jako nowe zgłoszenie — widoczna na żywo u operatora, + tak jak każda inna odpowiedź. +- **Filtry przed śmieciowymi zgłoszeniami** — automatyczne odpowiedzi + (autorespondery, „poza biurem”, bounce/mailer-daemon) są rozpoznawane po + nagłówkach (`Auto-Submitted`, `Precedence`) i typowych frazach w temacie + (PL i EN) i **odrzucane bez tworzenia zgłoszenia**; dodatkowa lista + zablokowanych nadawców per skrzynka (domyślnie `mailer-daemon, postmaster, + no-reply, noreply`). +- **„Tylko użytkownicy z LDAP”** (Integracje → LDAP) działa identycznie dla + poczty jak dla formularza gościa na stronie głównej — jeśli włączone, e-mail + od nieznanego nadawcy (spoza LDAP i bez lokalnego konta) jest odrzucany, nie + tworzy zgłoszenia. +- **Folder po przetworzeniu / folder odrzuconych** (opcjonalnie) — jeśli + puste, wiadomość zostaje na miejscu tylko oznaczona jako przeczytana. +- **Przycisk „Pobierz teraz”** przy każdej skrzynce — ręczne, natychmiastowe + sprawdzenie poczty bez czekania na harmonogram (co 5 minut), działa też dla + wyłączonej skrzynki; pokazuje od razu liczbę nowych/odpowiedzi/odrzuconych/ + błędów. +- **Przycisk „Testuj połączenie”** sprawdza niezapisane wartości formularza, + bez zapisywania. +- **Log** — cała aktywność (połączenia, każda decyzja per wiadomość, błędy) + trafia do osobnego pliku `storage/logs/imap-*.log`, niezależnie od + ogólnego poziomu logowania aplikacji — najlepsze miejsce do sprawdzenia, + dlaczego dany e-mail się nie przetworzył. +- **Znacznik „e-mail"** — zgłoszenie i pojedyncze wiadomości utworzone z + poczty mają widoczną ikonę koperty w kolejce operatora i w widoku + zgłoszenia, odróżniając je od zgłoszeń/odpowiedzi z formularza web. + +> Sprawdzanie skrzynek działa cyklicznie tylko wtedy, gdy na serwerze jest +> skonfigurowany zewnętrzny cron wywołujący `php artisan schedule:run` (patrz +> [install.md](../../install.md)) — bez tego działa wyłącznie przycisk +> „Pobierz teraz”. ## Integracje diff --git a/wiki/operator/README.md b/wiki/operator/README.md index b17b163..781fc85 100644 --- a/wiki/operator/README.md +++ b/wiki/operator/README.md @@ -49,7 +49,13 @@ swoje. **Akcje zbiorcze**: zaznacz kilka zgłoszeń checkboxami, by je **scalić** (pierwsze zaznaczone staje się główne, reszta trafia do niego jako wiadomości i zostaje -zamknięta) albo **usunąć**. +zamknięta) albo **usunąć**. Checkbox w nagłówku tabeli zaznacza/odznacza od +razu wszystkie zgłoszenia aktualnie widoczne pod bieżącym filtrem/zakładką +(nie wszystkie w systemie). + +Zgłoszenie założone albo odpowiedziane przez e-mail (patrz konfiguracja w +Admin > Poczta) ma widoczną ikonę koperty obok numeru w kolejce oraz przy +konkretnej wiadomości w wątku zgłoszenia. Kolejka aktualizuje się **na żywo** — nowe zgłoszenie, zmiana statusu/priorytetu/ przypisania czy nowa odpowiedź pojawiają się bez odświeżania strony. Obok
filteredTickets->isNotEmpty() && empty($this->filteredTickets->pluck('id')->diff($selectedIds)->all())) wire:click="toggleSelectAll" title="Zaznacz wszystkie"> @@ -161,7 +161,12 @@
id, $selectedIds)) wire:click="toggleSelect({{ $t->id }})">{{ $t->displayNumber() }} + {{ $t->displayNumber() }} + @if ($t->source === 'email') + mail + @endif + {{ $t->subject }}