From 0d116dfd983d0161a95496b84e10fd9c9572d73d Mon Sep 17 00:00:00 2001 From: Kacper Date: Thu, 23 Jul 2026 12:44:30 +0200 Subject: [PATCH] v1.2.0 - E-mail intake (IMAP), optional and off by default: clients can create a ticket or reply to an existing one just by sending/replying to an e-mail. Configure any number of mailboxes in the new Admin > Poczta page (SMTP + IMAP together, replacing the old "E-MAIL" tab), each routed to a specific subcategory or a whole category (new tickets.category_id column). Replies are matched to their ticket via the number/checksum already in every notification subject; autoresponders/bounces are detected and rejected; "restrict tickets to LDAP" is enforced for e-mail like the guest web form. Manual "Pobierz teraz" per-mailbox fetch button; dedicated storage/logs/imap-*.log regardless of the app's log level; mail-icon badges on e-mail-originated tickets/messages in the operator queue and ticket view. - Operator queue: "select all" checkbox in the table header for every currently visible ticket under the active filter/tab. - Fixed: scheduled commands (SLA breach check, automation rules, and now IMAP fetch) always sent notifications through .env's default mailer instead of the configured SMTP server, because AppServiceProvider's Settings override used to skip itself for any console command, not just migrate. - Fixed: visiting a ticket that no longer exists (deleted mid-session, or a stale background refresh) showed a raw 404 instead of redirecting back to the operator queue / client dashboard. - Docs: README/ARCHITECTURE/CLAUDE/install/wiki updated for all of the above, including the previously-missing host crontab entry for schedule:run. Co-Authored-By: Claude Sonnet 5 --- ARCHITECTURE.md | 107 +++++- CHANGELOG.md | 61 ++++ CLAUDE.md | 24 ++ README.md | 20 +- install.md | 16 +- src/app/Console/Commands/FetchImapEmails.php | 33 ++ src/app/Livewire/Admin/MailSettings.php | 335 ++++++++++++++++++ src/app/Livewire/Admin/Panel.php | 86 ----- src/app/Livewire/Operator/Queue.php | 24 +- src/app/Models/ImapMailbox.php | 65 ++++ src/app/Models/Ticket.php | 16 +- src/app/Models/TicketMessage.php | 2 +- src/app/Providers/AppServiceProvider.php | 16 +- src/app/Services/ImapMailboxFetcher.php | 282 +++++++++++++++ src/app/Services/ImapMessageClassifier.php | 137 +++++++ src/app/Services/TicketService.php | 38 +- src/app/Support/Imap/InboundEmail.php | 49 +++ src/bootstrap/app.php | 27 ++ src/composer.json | 3 +- src/composer.lock | 83 ++++- src/config/logging.php | 13 + ..._23_000153_create_imap_mailboxes_table.php | 36 ++ ...154_add_category_and_source_to_tickets.php | 42 +++ ...3_000155_add_source_to_ticket_messages.php | 29 ++ .../livewire/admin/mail-settings.blade.php | 182 ++++++++++ .../views/livewire/admin/panel.blade.php | 42 +-- .../views/livewire/operator/queue.blade.php | 9 +- .../livewire/operator/ticket-show.blade.php | 16 +- src/routes/console.php | 1 + ...DeletedTicketRedirectsInsteadOf404Test.php | 31 ++ .../ImapCategoryRoutingAndSourceBadgeTest.php | 203 +++++++++++ .../Feature/ImapMessageClassifierTest.php | 215 +++++++++++ src/tests/Feature/MailSmtpConfigTest.php | 39 +- .../Feature/OperatorQueueSelectAllTest.php | 39 ++ .../Feature/TicketNumberObfuscationTest.php | 7 +- .../Feature/TicketServiceGuestReplyTest.php | 61 ++++ wiki/admin/README.md | 57 ++- wiki/operator/README.md | 8 +- 38 files changed, 2290 insertions(+), 164 deletions(-) create mode 100644 src/app/Console/Commands/FetchImapEmails.php create mode 100644 src/app/Livewire/Admin/MailSettings.php create mode 100644 src/app/Models/ImapMailbox.php create mode 100644 src/app/Services/ImapMailboxFetcher.php create mode 100644 src/app/Services/ImapMessageClassifier.php create mode 100644 src/app/Support/Imap/InboundEmail.php create mode 100644 src/database/migrations/2026_07_23_000153_create_imap_mailboxes_table.php create mode 100644 src/database/migrations/2026_07_23_000154_add_category_and_source_to_tickets.php create mode 100644 src/database/migrations/2026_07_23_000155_add_source_to_ticket_messages.php create mode 100644 src/resources/views/livewire/admin/mail-settings.blade.php create mode 100644 src/tests/Feature/DeletedTicketRedirectsInsteadOf404Test.php create mode 100644 src/tests/Feature/ImapCategoryRoutingAndSourceBadgeTest.php create mode 100644 src/tests/Feature/ImapMessageClassifierTest.php create mode 100644 src/tests/Feature/OperatorQueueSelectAllTest.php create mode 100644 src/tests/Feature/TicketServiceGuestReplyTest.php 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 }}