v1.2.0
- E-mail intake (IMAP), optional and off by default: clients can create a ticket or reply to an existing one just by sending/replying to an e-mail. Configure any number of mailboxes in the new Admin > Poczta page (SMTP + IMAP together, replacing the old "E-MAIL" tab), each routed to a specific subcategory or a whole category (new tickets.category_id column). Replies are matched to their ticket via the number/checksum already in every notification subject; autoresponders/bounces are detected and rejected; "restrict tickets to LDAP" is enforced for e-mail like the guest web form. Manual "Pobierz teraz" per-mailbox fetch button; dedicated storage/logs/imap-*.log regardless of the app's log level; mail-icon badges on e-mail-originated tickets/messages in the operator queue and ticket view. - Operator queue: "select all" checkbox in the table header for every currently visible ticket under the active filter/tab. - Fixed: scheduled commands (SLA breach check, automation rules, and now IMAP fetch) always sent notifications through .env's default mailer instead of the configured SMTP server, because AppServiceProvider's Settings override used to skip itself for any console command, not just migrate. - Fixed: visiting a ticket that no longer exists (deleted mid-session, or a stale background refresh) showed a raw 404 instead of redirecting back to the operator queue / client dashboard. - Docs: README/ARCHITECTURE/CLAUDE/install/wiki updated for all of the above, including the previously-missing host crontab entry for schedule:run. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
107
ARCHITECTURE.md
107
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
|
own `#{numer}`, so adding the prefix there too would double it up or clash
|
||||||
with a non-default prefix.
|
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
|
## Roles & permissions
|
||||||
|
|
||||||
`$user->roles` reads/writes as a plain array (`['client', 'operator']`), but
|
`$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,
|
over the `settings` table, with hardcoded defaults for every key (company name,
|
||||||
LDAP/SMTP connection details, attachment limits, session lifetime, timezone,
|
LDAP/SMTP connection details, attachment limits, session lifetime, timezone,
|
||||||
branding/email HTML, etc.). Admin > Konfiguracja (general/attachments/session),
|
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
|
`AppServiceProvider::boot()` re-applies the relevant subset of it over
|
||||||
`config()` on every request — meaning **`Setting` rows win over `.env`** for
|
`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
|
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/
|
documented in [install.md](install.md) — anything touching LDAP/mail/session/
|
||||||
timezone config should go through `Settings`, not raw `config()`/`.env` reads.
|
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
|
## Notifications
|
||||||
|
|
||||||
`TicketService::notify(Ticket $ticket, string $triggerKey)` is the single
|
`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
|
this run, but a later rule's own query naturally excludes an already-closed
|
||||||
ticket.
|
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
|
## API
|
||||||
|
|
||||||
`routes/api.php` + `app/Http/Controllers/Api/` expose a small ability-scoped REST
|
`routes/api.php` + `app/Http/Controllers/Api/` expose a small ability-scoped REST
|
||||||
|
|||||||
61
CHANGELOG.md
61
CHANGELOG.md
@@ -3,6 +3,67 @@
|
|||||||
All notable changes to this project are documented in this file. Format loosely
|
All notable changes to this project are documented in this file. Format loosely
|
||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
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
|
## [1.1.4] - 2026-07-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
24
CLAUDE.md
24
CLAUDE.md
@@ -60,6 +60,30 @@ with no rebuild or restart:
|
|||||||
view:clear` to flush any root-owned compiled views before ending the
|
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.
|
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
|
## Apache `/icons/` alias trap
|
||||||
|
|
||||||
The stock `php:apache` image enables `mods-enabled/alias.conf`, which defines
|
The stock `php:apache` image enables `mods-enabled/alias.conf`, which defines
|
||||||
|
|||||||
20
README.md
20
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
|
- **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
|
attachment shows in the message thread as just its filename, opening in a new
|
||||||
tab on click (no inline image preview).
|
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
|
- **Configurable ticket numbering** (Admin > Konfiguracja) — a custom prefix and
|
||||||
minimum zero-padded length for the ticket number, plus an optional "hide
|
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
|
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
|
- **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
|
for CSS), LdapRecord for directory auth, Sanctum for API tokens, L5-Swagger for
|
||||||
API docs, Laravel Reverb for WebSocket broadcasting (real-time queue/chat
|
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
|
- **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
|
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
|
(`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/Livewire/ Client/Operator/Admin Livewire components
|
||||||
app/Models/ Eloquent models
|
app/Models/ Eloquent models
|
||||||
app/Events/ Broadcast events (TicketQueueChanged, TicketMessagePosted)
|
app/Events/ Broadcast events (TicketQueueChanged, TicketMessagePosted)
|
||||||
app/Console/Commands/ Scheduled commands (SLA breach check, automation rules)
|
app/Console/Commands/ Scheduled commands (SLA breach check, automation rules, IMAP fetch)
|
||||||
app/Services/ TicketService (ticket lifecycle + notifications), BookStackClient
|
app/Services/ TicketService (ticket lifecycle + notifications), BookStackClient,
|
||||||
|
ImapMailboxFetcher (I/O) + ImapMessageClassifier (pure logic)
|
||||||
app/Ldap/ LDAP user model + sync handlers
|
app/Ldap/ LDAP user model + sync handlers
|
||||||
database/migrations/ Schema (one file per table group, final shape)
|
database/migrations/ Schema (one file per table group, final shape)
|
||||||
database/seeders/ DatabaseSeeder — reference data, no ticket data
|
database/seeders/ DatabaseSeeder — reference data, no ticket data
|
||||||
|
|||||||
16
install.md
16
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/`.
|
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
|
`routes/console.php` planuje `tickets:check-sla-breaches` i `automation:run-rules`
|
||||||
Dockera nie ma wbudowanego cron/supervisora** — bez dodatkowego kroku to zadanie
|
co 15 minut oraz `emails:fetch-imap` (odbieranie zgłoszeń/odpowiedzi e-mailem —
|
||||||
nigdy się nie uruchomi. Najprościej dodać wpis crona **na hoście**:
|
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
|
```cron
|
||||||
* * * * * cd /ścieżka/do/repo && docker compose exec -T servicedesk php artisan schedule:run >> /dev/null 2>&1
|
* * * * * 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
|
```cron
|
||||||
* * * * * cd /var/www/servicedesk/src && php artisan schedule:run >> /dev/null 2>&1
|
* * * * * cd /var/www/servicedesk/src && php artisan schedule:run >> /dev/null 2>&1
|
||||||
|
|||||||
33
src/app/Console/Commands/FetchImapEmails.php
Normal file
33
src/app/Console/Commands/FetchImapEmails.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\ImapMailbox;
|
||||||
|
use App\Services\ImapMailboxFetcher;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class FetchImapEmails extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'emails:fetch-imap';
|
||||||
|
|
||||||
|
protected $description = 'Poll every enabled IMAP mailbox and turn new messages into tickets/replies';
|
||||||
|
|
||||||
|
public function handle(ImapMailboxFetcher $fetcher): int
|
||||||
|
{
|
||||||
|
if (! ImapMailbox::query()->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
335
src/app/Livewire/Admin/MailSettings.php
Normal file
335
src/app/Livewire/Admin/MailSettings.php
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Admin;
|
||||||
|
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\ImapMailbox;
|
||||||
|
use App\Services\ImapMailboxFetcher;
|
||||||
|
use App\Support\Settings;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Config;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SMTP (outbound) + IMAP mailboxes (inbound — turns e-mails into tickets or
|
||||||
|
* replies) on their own dedicated admin page, split out of the generic
|
||||||
|
* "Integracje" grab-bag since IMAP is a repeatable list (N mailboxes) rather
|
||||||
|
* than a singleton config, and both halves of "reply by e-mail" belong
|
||||||
|
* together rather than split across tabs.
|
||||||
|
*/
|
||||||
|
class MailSettings extends Component
|
||||||
|
{
|
||||||
|
public array $mailConfig = [];
|
||||||
|
|
||||||
|
public ?string $mailTestResult = null;
|
||||||
|
|
||||||
|
public bool $mailboxFormOpen = false;
|
||||||
|
|
||||||
|
public array $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',
|
||||||
|
];
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,8 +22,6 @@ use App\Services\LdapUserProvisioner;
|
|||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Config;
|
|
||||||
use Illuminate\Support\Facades\Mail;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use LdapRecord\Connection;
|
use LdapRecord\Connection;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
@@ -148,10 +146,6 @@ class Panel extends Component
|
|||||||
|
|
||||||
public ?string $ldapTestResult = null;
|
public ?string $ldapTestResult = null;
|
||||||
|
|
||||||
public array $mailConfig = [];
|
|
||||||
|
|
||||||
public ?string $mailTestResult = null;
|
|
||||||
|
|
||||||
public array $bookstackConfig = [];
|
public array $bookstackConfig = [];
|
||||||
|
|
||||||
public ?string $bookstackTestResult = null;
|
public ?string $bookstackTestResult = null;
|
||||||
@@ -201,17 +195,6 @@ class Panel extends Component
|
|||||||
'restrictTicketsToLdap' => Settings::bool('restrict_tickets_to_ldap'),
|
'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 = [
|
$this->bookstackConfig = [
|
||||||
'enabled' => Settings::bool('bookstack_enabled'),
|
'enabled' => Settings::bool('bookstack_enabled'),
|
||||||
'baseUrl' => Settings::get('bookstack_base_url'),
|
'baseUrl' => Settings::get('bookstack_base_url'),
|
||||||
@@ -1525,75 +1508,6 @@ class Panel extends Component
|
|||||||
$this->bookstackTestMessage = $result['message'];
|
$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 =====================
|
// ===================== GENERIC DELETE CONFIRM =====================
|
||||||
|
|
||||||
public function requestDelete(string $type, mixed $id, string $message): void
|
public function requestDelete(string $type, mixed $id, string $message): void
|
||||||
|
|||||||
@@ -298,7 +298,13 @@ class Queue extends Component
|
|||||||
$query->where('priority_key', $this->filterPriority);
|
$query->where('priority_key', $this->filterPriority);
|
||||||
}
|
}
|
||||||
if ($this->filterCategory !== 'all') {
|
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) {
|
if ($this->filterCustomerId) {
|
||||||
$query->where('customer_id', $this->filterCustomerId);
|
$query->where('customer_id', $this->filterCustomerId);
|
||||||
@@ -307,7 +313,7 @@ class Queue extends Component
|
|||||||
$query->search($this->search);
|
$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);
|
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
|
public function mergeSelected(): void
|
||||||
{
|
{
|
||||||
$ids = $this->selectedIdsInScope();
|
$ids = $this->selectedIdsInScope();
|
||||||
|
|||||||
65
src/app/Models/ImapMailbox.php
Normal file
65
src/app/Models/ImapMailbox.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One inbound mailbox polled by `emails:fetch-imap` — an admin can configure
|
||||||
|
* several (e.g. zgloszenia-it@ vs zgloszenia-delegacje@), each landing new
|
||||||
|
* tickets in its own default subcategory. Unlike LDAP/SMTP/BookStack, this is
|
||||||
|
* a list of N configs rather than a Settings singleton, so it's a real model
|
||||||
|
* rather than key/value rows.
|
||||||
|
*/
|
||||||
|
#[Fillable([
|
||||||
|
'name', 'enabled', 'host', 'port', 'encryption', 'validate_cert',
|
||||||
|
'username', 'password', 'folder', 'processed_folder', 'rejected_folder',
|
||||||
|
'default_subcategory_id', 'default_category_id', 'blocklist_senders', 'last_checked_at', 'last_error',
|
||||||
|
])]
|
||||||
|
class ImapMailbox extends Model
|
||||||
|
{
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'enabled' => '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 '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,8 +13,8 @@ use Illuminate\Support\Carbon;
|
|||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'number', 'checksum', 'customer_id', 'email', 'name', 'subcategory_id', 'subject', 'body',
|
'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',
|
'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',
|
'sla_notified_at', 'last_customer_activity_at', 'time_spent_seconds', 'timer_started_at',
|
||||||
'created_at', 'updated_at', 'csat_rating', 'csat_comment', 'csat_rated_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);
|
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
|
public function watchers(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(User::class, 'ticket_watchers');
|
return $this->belongsToMany(User::class, 'ticket_watchers');
|
||||||
@@ -214,7 +224,7 @@ class Ticket extends Model
|
|||||||
|
|
||||||
public function categoryLabel(): string
|
public function categoryLabel(): string
|
||||||
{
|
{
|
||||||
return $this->subcategory?->label() ?? '';
|
return $this->subcategory?->label() ?? $this->category?->name ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
|
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
|
class TicketMessage extends Model
|
||||||
{
|
{
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
|
|||||||
@@ -85,13 +85,21 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Avoid touching the DB during artisan commands that run before the
|
* Avoid touching the DB during the specific artisan commands that run
|
||||||
* `settings` table exists (e.g. `migrate` itself), or before it can be
|
* before the `settings` table exists or could be mid-schema-change (the
|
||||||
* queried at all — shared by every settings-driven config override below.
|
* 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
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
282
src/app/Services/ImapMailboxFetcher.php
Normal file
282
src/app/Services/ImapMailboxFetcher.php
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\ImapMailbox;
|
||||||
|
use App\Support\Imap\InboundEmail;
|
||||||
|
use App\Support\Settings;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Throwable;
|
||||||
|
use Webklex\PHPIMAP\Client;
|
||||||
|
use Webklex\PHPIMAP\ClientManager;
|
||||||
|
use Webklex\PHPIMAP\Message;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* I/O layer for the "reply/create ticket by e-mail" feature — connects to
|
||||||
|
* every enabled ImapMailbox, fetches unseen messages and delegates every
|
||||||
|
* decision to ImapMessageClassifier (pure logic) + TicketService (the
|
||||||
|
* existing ticket-mutation API). Kept thin and mostly untested directly;
|
||||||
|
* ImapMessageClassifier carries the actual test coverage.
|
||||||
|
*/
|
||||||
|
class ImapMailboxFetcher
|
||||||
|
{
|
||||||
|
private const HEADER_FIELDS = ['auto-submitted', 'x-autoreply', 'x-autorespond', 'precedence'];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ImapMessageClassifier $classifier,
|
||||||
|
private readonly TicketService $tickets,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{created: int, replied: int, rejected: int, errors: int}
|
||||||
|
*/
|
||||||
|
public function fetchAll(): array
|
||||||
|
{
|
||||||
|
$totals = ['created' => 0, 'replied' => 0, 'rejected' => 0, 'errors' => 0];
|
||||||
|
|
||||||
|
foreach (ImapMailbox::query()->where('enabled', true)->get() as $mailbox) {
|
||||||
|
foreach ($this->fetchMailbox($mailbox) as $key => $value) {
|
||||||
|
$totals[$key] += $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{created: int, replied: int, rejected: int, errors: int}
|
||||||
|
*/
|
||||||
|
public function fetchMailbox(ImapMailbox $mailbox): array
|
||||||
|
{
|
||||||
|
$result = ['created' => 0, 'replied' => 0, 'rejected' => 0, 'errors' => 0];
|
||||||
|
$log = Log::channel('imap');
|
||||||
|
|
||||||
|
$log->info("[{$mailbox->name}] łączenie z {$mailbox->host}:{$mailbox->port} (folder: {$mailbox->folder})");
|
||||||
|
|
||||||
|
try {
|
||||||
|
$client = $this->connect($mailbox);
|
||||||
|
$folder = $client->getFolder($mailbox->folder ?: 'INBOX');
|
||||||
|
$messages = $folder->messages()->whereUnseen()->get();
|
||||||
|
|
||||||
|
$log->info("[{$mailbox->name}] {$messages->count()} nieprzeczytanych wiadomości");
|
||||||
|
|
||||||
|
foreach ($messages as $message) {
|
||||||
|
try {
|
||||||
|
$this->processMessage($mailbox, $message, $result, $log);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$result['errors']++;
|
||||||
|
$log->error("[{$mailbox->name}] błąd przetwarzania wiadomości (uid={$message->getUid()}) — {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$client->disconnect();
|
||||||
|
$mailbox->update(['last_checked_at' => now(), 'last_error' => null]);
|
||||||
|
$log->info("[{$mailbox->name}] zakończono: {$result['created']} nowych, {$result['replied']} odpowiedzi, {$result['rejected']} odrzuconych, {$result['errors']} błędów");
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$result['errors']++;
|
||||||
|
$mailbox->update(['last_checked_at' => now(), 'last_error' => $e->getMessage()]);
|
||||||
|
$log->error("[{$mailbox->name}] połączenie nieudane — {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a connection and lists the configured folder, without fetching
|
||||||
|
* or touching any message — used by the admin "Testuj połączenie" button.
|
||||||
|
* Returns null on success, the exception message on failure.
|
||||||
|
*/
|
||||||
|
public function testConnection(ImapMailbox $mailbox): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$client = $this->connect($mailbox);
|
||||||
|
$client->getFolder($mailbox->folder ?: 'INBOX');
|
||||||
|
$client->disconnect();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function connect(ImapMailbox $mailbox): Client
|
||||||
|
{
|
||||||
|
$manager = new ClientManager;
|
||||||
|
$client = $manager->make([
|
||||||
|
'host' => $mailbox->host,
|
||||||
|
'port' => $mailbox->port,
|
||||||
|
'protocol' => 'imap',
|
||||||
|
'encryption' => $mailbox->encryption === 'none' ? false : $mailbox->encryption,
|
||||||
|
'validate_cert' => $mailbox->validate_cert,
|
||||||
|
'username' => $mailbox->username,
|
||||||
|
'password' => $mailbox->password,
|
||||||
|
]);
|
||||||
|
$client->connect();
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{created: int, replied: int, rejected: int, errors: int} $result
|
||||||
|
*/
|
||||||
|
private function processMessage(ImapMailbox $mailbox, Message $message, array &$result, LoggerInterface $log): void
|
||||||
|
{
|
||||||
|
$email = $this->toInboundEmail($message);
|
||||||
|
$uid = $message->getUid();
|
||||||
|
|
||||||
|
$log->debug("[{$mailbox->name}] uid={$uid} od={$email->fromEmail} temat=\"{$email->subject}\" nagłówki=".json_encode($email->headers, JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
$rejectReason = $this->classifier->rejectionReason($email, $mailbox->blocklistedSenders());
|
||||||
|
if ($rejectReason === null && ! $this->classifier->isSenderAllowed($email->fromEmail)) {
|
||||||
|
$rejectReason = "nadawca spoza LDAP ({$email->fromEmail}), a restrict_tickets_to_ldap jest włączone";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rejectReason !== null) {
|
||||||
|
$this->finish($message, $mailbox->rejected_folder);
|
||||||
|
$result['rejected']++;
|
||||||
|
$log->info("[{$mailbox->name}] uid={$uid} ODRZUCONO od {$email->fromEmail} \"{$email->subject}\" — {$rejectReason}");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oznacz/przenieś PRZED utworzeniem ticketu: awaria w tym miejscu
|
||||||
|
// zostawia co najwyżej "przetworzoną" wiadomość bez ticketu (widoczne,
|
||||||
|
// łatwe do naprawienia ręcznie) zamiast duplikatu ticketu przy
|
||||||
|
// ponownym uruchomieniu.
|
||||||
|
$this->finish($message, $mailbox->processed_folder);
|
||||||
|
|
||||||
|
$ticket = $this->classifier->matchTicket($email->subject);
|
||||||
|
$sender = $this->classifier->resolveSender($email->fromEmail);
|
||||||
|
$attachments = $this->buildAttachments($email, $mailbox, $log);
|
||||||
|
$authorName = $email->fromName !== '' ? $email->fromName : $email->fromEmail;
|
||||||
|
|
||||||
|
if ($ticket) {
|
||||||
|
if ($sender) {
|
||||||
|
$this->tickets->clientReply($ticket, $sender, $email->body(), $attachments, source: 'email');
|
||||||
|
} else {
|
||||||
|
$this->tickets->guestReply($ticket, $authorName, $email->body(), $attachments, source: 'email');
|
||||||
|
}
|
||||||
|
$result['replied']++;
|
||||||
|
$log->info("[{$mailbox->name}] uid={$uid} ODPOWIEDŹ od {$email->fromEmail} dopisana do zgłoszenia #{$ticket->id} ({$ticket->displayNumber()})");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$newTicket = $this->tickets->create([
|
||||||
|
'email' => $email->fromEmail,
|
||||||
|
'name' => $authorName,
|
||||||
|
'subcategory_id' => $mailbox->default_subcategory_id,
|
||||||
|
'category_id' => $mailbox->default_category_id,
|
||||||
|
'subject' => $email->subject !== '' ? $email->subject : '(bez tematu)',
|
||||||
|
'body' => $email->body(),
|
||||||
|
'source' => 'email',
|
||||||
|
], $sender, $authorName);
|
||||||
|
$result['created']++;
|
||||||
|
$log->info("[{$mailbox->name}] uid={$uid} NOWE zgłoszenie #{$newTicket->id} ({$newTicket->displayNumber()}) od {$email->fromEmail}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function finish(Message $message, ?string $moveToFolder): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$message->setFlag('Seen');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::channel('imap')->warning("IMAP: nie udało się oznaczyć wiadomości jako przeczytanej — {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($moveToFolder) {
|
||||||
|
$message->move($moveToFolder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function toInboundEmail(Message $message): InboundEmail
|
||||||
|
{
|
||||||
|
$fromAddress = $message->getFrom()->first();
|
||||||
|
$header = $message->getHeader();
|
||||||
|
|
||||||
|
// Webklex's Header::get() returns an *empty* Attribute (not null)
|
||||||
|
// for a header that isn't present at all, and Attribute::first() on
|
||||||
|
// that empty instance comes back as '' rather than null — so a
|
||||||
|
// plain "!== null" check on the resulting value is always true,
|
||||||
|
// making every message look like it carries every one of these
|
||||||
|
// headers. Only keep a header that actually has content.
|
||||||
|
$headers = [];
|
||||||
|
foreach (self::HEADER_FIELDS as $name) {
|
||||||
|
$value = $header?->get($name)->first();
|
||||||
|
if ($value !== null && $value !== '') {
|
||||||
|
$headers[$name] = (string) $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InboundEmail(
|
||||||
|
fromEmail: $fromAddress?->mail ?? '',
|
||||||
|
fromName: $this->decodeHeaderText(trim((string) ($fromAddress?->personal ?? ''), '"')),
|
||||||
|
subject: $this->decodeHeaderText((string) $message->getSubject()),
|
||||||
|
textBody: (string) $message->getTextBody(),
|
||||||
|
htmlBody: (string) $message->getHTMLBody(),
|
||||||
|
headers: $headers,
|
||||||
|
attachments: $this->extractAttachments($message),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some senders' mail clients leave the Subject/From display-name as raw
|
||||||
|
* RFC 2047 encoded-words (e.g. "=?utf-8?Q?...?=") instead of the
|
||||||
|
* decoded UTF-8 webklex's own config claims to produce — decode
|
||||||
|
* defensively rather than showing garbled text on the ticket.
|
||||||
|
*/
|
||||||
|
private function decodeHeaderText(string $value): string
|
||||||
|
{
|
||||||
|
return $value !== '' ? mb_decode_mimeheader($value) : $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{filename: string, mime: string, content: string}>
|
||||||
|
*/
|
||||||
|
private function extractAttachments(Message $message): array
|
||||||
|
{
|
||||||
|
$attachments = [];
|
||||||
|
|
||||||
|
foreach ($message->getAttachments() as $attachment) {
|
||||||
|
$attachments[] = [
|
||||||
|
'filename' => $attachment->getName() ?: 'attachment',
|
||||||
|
'mime' => $attachment->getMimeType() ?: 'application/octet-stream',
|
||||||
|
'content' => $attachment->getContent(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attachments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts raw attachment bytes into UploadedFile instances (via a temp
|
||||||
|
* file + the $test=true flag, which lets Symfony's UploadedFile skip the
|
||||||
|
* is_uploaded_file() check outside of a real HTTP request) so they flow
|
||||||
|
* through TicketService::attachFiles() unchanged. Validated the same way
|
||||||
|
* every other caller validates before calling attachFiles() — a mail
|
||||||
|
* carrying an oversized/disallowed attachment still creates the
|
||||||
|
* ticket/reply, just without that attachment, rather than being dropped
|
||||||
|
* entirely or silently bypassing the admin's attachment policy.
|
||||||
|
*
|
||||||
|
* @return UploadedFile[]
|
||||||
|
*/
|
||||||
|
private function buildAttachments(InboundEmail $email, ImapMailbox $mailbox, LoggerInterface $log): array
|
||||||
|
{
|
||||||
|
$files = [];
|
||||||
|
|
||||||
|
foreach ($email->attachments as $attachment) {
|
||||||
|
$path = tempnam(sys_get_temp_dir(), 'imap_');
|
||||||
|
file_put_contents($path, $attachment['content']);
|
||||||
|
$files[] = new UploadedFile($path, $attachment['filename'], $attachment['mime'], null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($files && ($error = Settings::validateAttachments($files))) {
|
||||||
|
$log->warning("[{$mailbox->name}] pominięto załączniki wiadomości od {$email->fromEmail} — {$error}");
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/app/Services/ImapMessageClassifier.php
Normal file
137
src/app/Services/ImapMessageClassifier.php
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Imap\InboundEmail;
|
||||||
|
use App\Support\Settings;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure decision logic for the IMAP fetcher — no IMAP connection, no
|
||||||
|
* side effects, so it's fully Pest-testable against hand-built
|
||||||
|
* InboundEmail instances. ImapMailboxFetcher does all the I/O and calls
|
||||||
|
* into this for every decision.
|
||||||
|
*/
|
||||||
|
class ImapMessageClassifier
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* RFC 3834 (Auto-Submitted) + common vendor headers, plus EN/PL subject
|
||||||
|
* phrasing for autoresponders/bounces that don't set those headers at
|
||||||
|
* all — the two layers catch most real-world autoresponders/mailer-daemons.
|
||||||
|
*/
|
||||||
|
private const AUTO_REPLY_SUBJECT_PATTERNS = [
|
||||||
|
'/\bout of office\b/i',
|
||||||
|
'/\bautomatic reply\b/i',
|
||||||
|
'/\bautomatyczna odpowiedz\b/iu',
|
||||||
|
'/\bautoresponder\b/i',
|
||||||
|
'/\bundeliverable\b/i',
|
||||||
|
'/\bundelivered\b/i',
|
||||||
|
'/\bmail delivery failed\b/i',
|
||||||
|
'/\bdelivery status notification\b/i',
|
||||||
|
'/\bnieobecnosc\b.*\bbiurze\b/iu',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a human-readable rejection reason, or null if the message
|
||||||
|
* should be processed as a genuine ticket/reply.
|
||||||
|
*
|
||||||
|
* @param string[] $extraBlocklist additional blocked sender local-parts/addresses (per-mailbox)
|
||||||
|
*/
|
||||||
|
public function rejectionReason(InboundEmail $email, array $extraBlocklist = []): ?string
|
||||||
|
{
|
||||||
|
$autoSubmitted = strtolower((string) $email->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,10 @@ class TicketService
|
|||||||
'email' => $customer?->email ?? $data['email'],
|
'email' => $customer?->email ?? $data['email'],
|
||||||
'name' => $customer?->name ?? ($data['name'] ?? $data['email']),
|
'name' => $customer?->name ?? ($data['name'] ?? $data['email']),
|
||||||
'subcategory_id' => $subcategory?->id,
|
'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'],
|
'subject' => $data['subject'],
|
||||||
'body' => $data['body'],
|
'body' => $data['body'],
|
||||||
'status_key' => Settings::get('default_status', 'new'),
|
'status_key' => Settings::get('default_status', 'new'),
|
||||||
@@ -50,6 +54,7 @@ class TicketService
|
|||||||
'assignee_id' => $data['assignee_id'] ?? null,
|
'assignee_id' => $data['assignee_id'] ?? null,
|
||||||
'custom_fields' => $data['custom_values'] ?? [],
|
'custom_fields' => $data['custom_values'] ?? [],
|
||||||
'last_customer_activity_at' => now(),
|
'last_customer_activity_at' => now(),
|
||||||
|
'source' => $data['source'] ?? 'web',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$message = $ticket->messages()->create([
|
$message = $ticket->messages()->create([
|
||||||
@@ -213,11 +218,12 @@ class TicketService
|
|||||||
TicketMessagePosted::dispatch($ticket->id, $message->id, true, $operator->id);
|
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([
|
$message = $ticket->messages()->create([
|
||||||
'author_name' => $client->name,
|
'author_name' => $client->name,
|
||||||
'body' => $body,
|
'body' => $body,
|
||||||
|
'source' => $source === 'web' ? null : $source,
|
||||||
]);
|
]);
|
||||||
$message->attachAuthor($client->id, 'client');
|
$message->attachAuthor($client->id, 'client');
|
||||||
$ticket->touch();
|
$ticket->touch();
|
||||||
@@ -238,6 +244,36 @@ class TicketService
|
|||||||
TicketQueueChanged::dispatch($ticket->id, 'message_posted', $client->id);
|
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
|
public function toggleWatch(Ticket $ticket, User $user): bool
|
||||||
{
|
{
|
||||||
if ($ticket->isWatchedBy($user)) {
|
if ($ticket->isWatchedBy($user)) {
|
||||||
|
|||||||
49
src/app/Support/Imap/InboundEmail.php
Normal file
49
src/app/Support/Imap/InboundEmail.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Imap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalized view of one inbound message, independent of the IMAP client
|
||||||
|
* library — the seam between ImapMailboxFetcher (I/O, effectively
|
||||||
|
* untestable without a real mailbox) and ImapMessageClassifier (pure
|
||||||
|
* decision logic, fully Pest-testable against hand-built instances).
|
||||||
|
*/
|
||||||
|
class InboundEmail
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $headers lower-cased header names
|
||||||
|
* @param array<int, array{filename: string, mime: string, content: string}> $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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Middleware\EnsureRole;
|
use App\Http\Middleware\EnsureRole;
|
||||||
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
|
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
|
||||||
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
|
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
@@ -44,4 +46,29 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
$exceptions->shouldRenderJsonWhen(
|
$exceptions->shouldRenderJsonWhen(
|
||||||
fn (Request $request) => $request->is('api/*'),
|
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();
|
})->create();
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
"laravel/reverb": "*",
|
"laravel/reverb": "*",
|
||||||
"laravel/sanctum": "*",
|
"laravel/sanctum": "*",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"livewire/livewire": "*"
|
"livewire/livewire": "*",
|
||||||
|
"webklex/php-imap": "*"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
|||||||
83
src/composer.lock
generated
83
src/composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "321add40614eb8751e0c8dbda55016eb",
|
"content-hash": "abe8bd31e8d8849ae593e562f73a39df",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@@ -7593,6 +7593,87 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-04-26T05:33:54+00:00"
|
"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",
|
"name": "zircote/swagger-php",
|
||||||
"version": "6.4.0",
|
"version": "6.4.0",
|
||||||
|
|||||||
@@ -73,6 +73,19 @@ return [
|
|||||||
'replace_placeholders' => true,
|
'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' => [
|
'slack' => [
|
||||||
'driver' => 'slack',
|
'driver' => 'slack',
|
||||||
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('imap_mailboxes', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* category_id lets a ticket carry just a Category with no specific
|
||||||
|
* Subcategory (e.g. an IMAP mailbox routed to "całą kategorię" rather
|
||||||
|
* than one subcategory) — subcategory_id already implies a category via
|
||||||
|
* its own relation, so category_id is only ever populated when there's
|
||||||
|
* no subcategory to derive it from (see Ticket::categoryLabel()).
|
||||||
|
*
|
||||||
|
* source records how the ticket was created (web/e-mail/...), surfaced
|
||||||
|
* as a badge in the operator queue/ticket view.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('tickets', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Mirrors tickets.source at the individual-message level — a ticket
|
||||||
|
* created on the web can still later receive a reply by e-mail (or vice
|
||||||
|
* versa), so this needs tracking per message, not just per ticket.
|
||||||
|
* Null means "web" (the original/default channel); only IMAP-originated
|
||||||
|
* messages ever set it to 'email'.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('ticket_messages', function (Blueprint $table) {
|
||||||
|
$table->string('source')->nullable()->after('api_client_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('ticket_messages', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('source');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
182
src/resources/views/livewire/admin/mail-settings.blade.php
Normal file
182
src/resources/views/livewire/admin/mail-settings.blade.php
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<div>
|
||||||
|
<h3 style="margin:0 0 14px">E-mail (SMTP)</h3>
|
||||||
|
<form wire:submit="saveMailConfig" class="card" style="padding:20px;gap:14px;max-width:480px;margin-bottom:32px">
|
||||||
|
<div class="field"><label>Adres nadawcy</label><input class="input" type="email" placeholder="wsparcie@firma.pl" wire:model="mailConfig.fromAddress"></div>
|
||||||
|
<div class="field"><label>Nazwa nadawcy</label><input class="input" placeholder="Zespół Wsparcia" wire:model="mailConfig.fromName"></div>
|
||||||
|
|
||||||
|
<div class="hr"></div>
|
||||||
|
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="mailConfig.smtpEnabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz wysyłkę przez własny serwer SMTP</strong></label>
|
||||||
|
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Bez włączenia aplikacja wysyła pocztę zgodnie z konfiguracją środowiska (.env).</span>
|
||||||
|
|
||||||
|
@if ($mailConfig['smtpEnabled'])
|
||||||
|
<div class="field"><label>Host SMTP</label><input class="input" placeholder="smtp.example.com" wire:model="mailConfig.smtpHost"></div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" placeholder="587" wire:model="mailConfig.smtpPort"></div>
|
||||||
|
<div class="field" style="flex:1">
|
||||||
|
<label>Szyfrowanie</label>
|
||||||
|
<select class="input" wire:model="mailConfig.smtpEncryption">
|
||||||
|
<option value="none">Brak</option>
|
||||||
|
<option value="tls">STARTTLS</option>
|
||||||
|
<option value="ssl">SSL/TLS</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Użytkownik</label><input class="input" wire:model="mailConfig.smtpUsername"></div>
|
||||||
|
<div class="field"><label>Hasło</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="mailConfig.smtpPassword"></div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
|
||||||
|
<button type="button" class="btn btn-secondary" wire:click="testMailConnection">Wyślij testową wiadomość</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Zapisz</button>
|
||||||
|
@if ($mailTestResult === 'ok')
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Wysłano na Twój adres</div>
|
||||||
|
@elseif ($mailTestResult === 'error')
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger)"><span class="material-symbols-outlined" style="font-size:18px">error</span>Błąd wysyłki</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
|
||||||
|
@endif
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
|
||||||
|
<h3 style="margin:0">Skrzynki IMAP (zgłoszenia i odpowiedzi przez e-mail)</h3>
|
||||||
|
<button class="btn btn-primary" type="button" wire:click="openMailboxForm">+ Nowa skrzynka</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if ($this->mailboxes->isNotEmpty())
|
||||||
|
<div class="table-wrap" style="margin-bottom:20px">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nazwa</th>
|
||||||
|
<th>Serwer</th>
|
||||||
|
<th>Użytkownik</th>
|
||||||
|
<th>Kategoria / podkategoria</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach ($this->mailboxes as $mailbox)
|
||||||
|
<tr>
|
||||||
|
<td>{{ $mailbox->name }}</td>
|
||||||
|
<td class="text-muted" style="white-space:nowrap">{{ $mailbox->host }}:{{ $mailbox->port }}</td>
|
||||||
|
<td class="text-muted">{{ $mailbox->username }}</td>
|
||||||
|
<td class="text-muted">{{ $mailbox->targetLabel() }}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="tag" style="border:none;cursor:pointer;background:color-mix(in srgb, var(--color-{{ $mailbox->enabled ? 'success' : 'danger' }}) 18%, transparent);color:var(--color-{{ $mailbox->enabled ? 'success' : 'danger' }})"
|
||||||
|
wire:click="toggleMailboxEnabled({{ $mailbox->id }})">
|
||||||
|
{{ $mailbox->enabled ? 'Włączona' : 'Wyłączona' }}
|
||||||
|
</button>
|
||||||
|
@if ($mailbox->last_error)
|
||||||
|
<div style="color:var(--color-danger);font-size:11px;margin-top:4px">{{ $mailbox->last_error }}</div>
|
||||||
|
@elseif ($mailbox->last_checked_at)
|
||||||
|
<div class="text-muted" style="font-size:11px;margin-top:4px">Sprawdzono: {{ $mailbox->last_checked_at->format('Y-m-d H:i') }}</div>
|
||||||
|
@endif
|
||||||
|
@if ($mailboxFetchResultId === $mailbox->id)
|
||||||
|
<div class="text-muted" style="font-size:11px;margin-top:4px">{{ $mailboxFetchSummary }}</div>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style="display:flex;gap:6px;justify-content:flex-end">
|
||||||
|
<button class="btn btn-ghost" type="button" wire:click="fetchMailboxNow({{ $mailbox->id }})" wire:loading.attr="disabled" wire:target="fetchMailboxNow({{ $mailbox->id }})">Pobierz teraz</button>
|
||||||
|
<button class="btn btn-ghost" type="button" wire:click="editMailbox({{ $mailbox->id }})">Edytuj</button>
|
||||||
|
<button class="btn btn-ghost" type="button" wire:click="removeMailbox({{ $mailbox->id }})" wire:confirm="Usunąć tę skrzynkę IMAP?">Usuń</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<p class="text-muted" style="font-size:13px">Brak skonfigurowanych skrzynek IMAP. Dodaj pierwszą używając przycisku wyżej.</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($mailboxFormOpen)
|
||||||
|
<div class="dialog-backdrop">
|
||||||
|
<form wire:submit="submitMailboxForm" class="dialog" style="max-width:520px;max-height:90vh;overflow-y:auto">
|
||||||
|
<div class="dialog-title">{{ $mailboxForm['id'] ? 'Edytuj skrzynkę IMAP' : 'Nowa skrzynka IMAP' }}</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Nazwa (etykieta)</label>
|
||||||
|
<input class="input" placeholder="np. Zgłoszenia IT" wire:model="mailboxForm.name">
|
||||||
|
</div>
|
||||||
|
@error('mailboxForm.name') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||||
|
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="mailboxForm.enabled" style="position:static;opacity:1;width:auto;height:auto">Włączona</label>
|
||||||
|
|
||||||
|
<div class="hr"></div>
|
||||||
|
|
||||||
|
<div class="field"><label>Host IMAP</label><input class="input" placeholder="imap.firma.pl" wire:model="mailboxForm.host"></div>
|
||||||
|
@error('mailboxForm.host') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||||
|
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" wire:model="mailboxForm.port"></div>
|
||||||
|
<div class="field" style="flex:1">
|
||||||
|
<label>Szyfrowanie</label>
|
||||||
|
<select class="input" wire:model="mailboxForm.encryption">
|
||||||
|
<option value="ssl">SSL/TLS</option>
|
||||||
|
<option value="tls">STARTTLS</option>
|
||||||
|
<option value="none">Brak</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="mailboxForm.validateCert" style="position:static;opacity:1;width:auto;height:auto">Weryfikuj certyfikat TLS</label>
|
||||||
|
|
||||||
|
<div class="field"><label>Adres skrzynki (login)</label><input class="input" placeholder="zgloszenia-it@firma.pl" wire:model="mailboxForm.username"></div>
|
||||||
|
@error('mailboxForm.username') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||||
|
<div class="field"><label>Hasło</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="mailboxForm.password"></div>
|
||||||
|
|
||||||
|
<div class="hr"></div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Kategoria / podkategoria nowych zgłoszeń</label>
|
||||||
|
<select class="input" wire:model="mailboxForm.target">
|
||||||
|
<option value="">Brak (zgłoszenie nieprzypisane)</option>
|
||||||
|
@foreach ($this->categoryOptions as $category)
|
||||||
|
<optgroup label="{{ $category['name'] }}">
|
||||||
|
<option value="category:{{ $category['id'] }}">Cała kategoria: {{ $category['name'] }}</option>
|
||||||
|
@foreach ($category['subcategories'] as $sub)
|
||||||
|
<option value="subcategory:{{ $sub['id'] }}">{{ $sub['name'] }}</option>
|
||||||
|
@endforeach
|
||||||
|
</optgroup>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<span class="text-muted" style="font-size:11.5px">Wybierz konkretną podkategorię (trafi też do jej zespołu) albo całą kategorię, jeśli nie chcesz przypisywać konkretnej podkategorii.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field"><label>Folder</label><input class="input" wire:model="mailboxForm.folder"></div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<div class="field" style="flex:1"><label>Folder po przetworzeniu (opcjonalnie)</label><input class="input" placeholder="pozostaw puste = oznacz jako przeczytane" wire:model="mailboxForm.processedFolder"></div>
|
||||||
|
<div class="field" style="flex:1"><label>Folder odrzuconych (opcjonalnie)</label><input class="input" placeholder="pozostaw puste = oznacz jako przeczytane" wire:model="mailboxForm.rejectedFolder"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Zablokowani nadawcy (dodatkowo do filtrów autoresponderów)</label>
|
||||||
|
<input class="input" wire:model="mailboxForm.blocklistSenders">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:4px">
|
||||||
|
<button type="button" class="btn btn-secondary" wire:click="testMailboxConnection">Testuj połączenie</button>
|
||||||
|
@if ($mailboxTestResult === 'ok')
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Połączono</div>
|
||||||
|
@elseif ($mailboxTestResult === 'error')
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger);font-size:12.5px"><span class="material-symbols-outlined" style="font-size:18px">error</span>{{ $mailboxTestMessage }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button class="btn btn-secondary" type="button" wire:click="closeMailboxForm">Anuluj</button>
|
||||||
|
<button class="btn btn-primary" type="submit">Zapisz</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@@ -17,7 +17,7 @@ $tabGroups = [
|
|||||||
],
|
],
|
||||||
'Ustawienia' => [
|
'Ustawienia' => [
|
||||||
['key' => 'templates', 'label' => 'Szablony e-mail', 'icon' => 'mail'],
|
['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' => 'branding', 'label' => 'Wygląd i branding', 'icon' => 'palette'],
|
||||||
['key' => 'config', 'label' => 'Konfiguracja', 'icon' => 'settings'],
|
['key' => 'config', 'label' => 'Konfiguracja', 'icon' => 'settings'],
|
||||||
['key' => 'integrations', 'label' => 'Integracje', 'icon' => 'hub'],
|
['key' => 'integrations', 'label' => 'Integracje', 'icon' => 'hub'],
|
||||||
@@ -483,45 +483,7 @@ $tabGroups = [
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 style="margin:0 0 14px">E-mail (SMTP)</h3>
|
<livewire:admin.mail-settings />
|
||||||
<form wire:submit="saveMailConfig" class="card" style="padding:20px;gap:14px;max-width:480px">
|
|
||||||
<div class="field"><label>Adres nadawcy</label><input class="input" type="email" placeholder="wsparcie@firma.pl" wire:model="mailConfig.fromAddress"></div>
|
|
||||||
<div class="field"><label>Nazwa nadawcy</label><input class="input" placeholder="Zespół Wsparcia" wire:model="mailConfig.fromName"></div>
|
|
||||||
|
|
||||||
<div class="hr"></div>
|
|
||||||
|
|
||||||
<label class="radio"><input type="checkbox" wire:model="mailConfig.smtpEnabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz wysyłkę przez własny serwer SMTP</strong></label>
|
|
||||||
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Bez włączenia aplikacja wysyła pocztę zgodnie z konfiguracją środowiska (.env).</span>
|
|
||||||
|
|
||||||
@if ($mailConfig['smtpEnabled'])
|
|
||||||
<div class="field"><label>Host SMTP</label><input class="input" placeholder="smtp.example.com" wire:model="mailConfig.smtpHost"></div>
|
|
||||||
<div style="display:flex;gap:10px">
|
|
||||||
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" placeholder="587" wire:model="mailConfig.smtpPort"></div>
|
|
||||||
<div class="field" style="flex:1">
|
|
||||||
<label>Szyfrowanie</label>
|
|
||||||
<select class="input" wire:model="mailConfig.smtpEncryption">
|
|
||||||
<option value="none">Brak</option>
|
|
||||||
<option value="tls">STARTTLS</option>
|
|
||||||
<option value="ssl">SSL/TLS</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field"><label>Użytkownik</label><input class="input" wire:model="mailConfig.smtpUsername"></div>
|
|
||||||
<div class="field"><label>Hasło</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="mailConfig.smtpPassword"></div>
|
|
||||||
|
|
||||||
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
|
|
||||||
<button type="button" class="btn btn-secondary" wire:click="testMailConnection">Wyślij testową wiadomość</button>
|
|
||||||
<button type="submit" class="btn btn-primary">Zapisz</button>
|
|
||||||
@if ($mailTestResult === 'ok')
|
|
||||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Wysłano na Twój adres</div>
|
|
||||||
@elseif ($mailTestResult === 'error')
|
|
||||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger)"><span class="material-symbols-outlined" style="font-size:18px">error</span>Błąd wysyłki</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
|
|
||||||
@endif
|
|
||||||
</form>
|
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- ================= BRANDING ================= --}}
|
{{-- ================= BRANDING ================= --}}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@
|
|||||||
<table class="table table-cards-mobile">
|
<table class="table table-cards-mobile">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th></th>
|
<th><input type="checkbox" @checked($this->filteredTickets->isNotEmpty() && empty($this->filteredTickets->pluck('id')->diff($selectedIds)->all())) wire:click="toggleSelectAll" title="Zaznacz wszystkie"></th>
|
||||||
@foreach ($columnDefs as $key => $label)
|
@foreach ($columnDefs as $key => $label)
|
||||||
@continue(! in_array($key, $visibleColumns))
|
@continue(! in_array($key, $visibleColumns))
|
||||||
<th>
|
<th>
|
||||||
@@ -161,7 +161,12 @@
|
|||||||
<tr wire:key="ticket-{{ $t->id }}">
|
<tr wire:key="ticket-{{ $t->id }}">
|
||||||
<td class="td-select"><input type="checkbox" @checked(in_array($t->id, $selectedIds)) wire:click="toggleSelect({{ $t->id }})"></td>
|
<td class="td-select"><input type="checkbox" @checked(in_array($t->id, $selectedIds)) wire:click="toggleSelect({{ $t->id }})"></td>
|
||||||
@if (in_array('number', $visibleColumns))
|
@if (in_array('number', $visibleColumns))
|
||||||
<td data-label="Numer" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->displayNumber() }}</a></td>
|
<td data-label="Numer" class="td-title">
|
||||||
|
<a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->displayNumber() }}</a>
|
||||||
|
@if ($t->source === 'email')
|
||||||
|
<span class="material-symbols-outlined" style="font-size:15px;vertical-align:-3px;opacity:0.7" title="Utworzone przez e-mail">mail</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
@endif
|
@endif
|
||||||
@if (in_array('subject', $visibleColumns))
|
@if (in_array('subject', $visibleColumns))
|
||||||
<td data-label="Temat" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer;white-space:nowrap">{{ $t->subject }}</a></td>
|
<td data-label="Temat" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer;white-space:nowrap">{{ $t->subject }}</a></td>
|
||||||
|
|||||||
@@ -36,7 +36,14 @@
|
|||||||
<div class="main-col" style="display:flex;flex-direction:column;gap:16px">
|
<div class="main-col" style="display:flex;flex-direction:column;gap:16px">
|
||||||
<div class="card" style="padding:20px;gap:8px">
|
<div class="card" style="padding:20px;gap:8px">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
|
||||||
<div class="card-kicker">Zgłoszenie {{ $ticket->displayNumber() }}</div>
|
<div style="display:flex;align-items:center;gap:8px">
|
||||||
|
<div class="card-kicker">Zgłoszenie {{ $ticket->displayNumber() }}</div>
|
||||||
|
@if ($ticket->source === 'email')
|
||||||
|
<span class="tag tag-outline" style="display:inline-flex;align-items:center;gap:3px;font-size:10.5px" title="Utworzone przez e-mail">
|
||||||
|
<span class="material-symbols-outlined" style="font-size:13px">mail</span>E-mail
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@unless ($editingDetails)
|
@unless ($editingDetails)
|
||||||
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="toggleEditDetails">Edytuj</button>
|
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="toggleEditDetails">Edytuj</button>
|
||||||
@endunless
|
@endunless
|
||||||
@@ -160,7 +167,12 @@
|
|||||||
<div wire:key="msg-{{ $m->id }}" style="display:flex;justify-content:{{ $mine ? 'flex-end' : 'flex-start' }}">
|
<div wire:key="msg-{{ $m->id }}" style="display:flex;justify-content:{{ $mine ? 'flex-end' : 'flex-start' }}">
|
||||||
<div style="max-width:75%;padding:10px 14px;border-radius:12px;font-size:14px;background:{{ $mine ? 'var(--color-accent-800)' : 'var(--color-surface)' }};color:{{ $mine ? 'var(--color-accent-100)' : 'var(--color-text)' }};border:1px solid var(--color-divider)">
|
<div style="max-width:75%;padding:10px 14px;border-radius:12px;font-size:14px;background:{{ $mine ? 'var(--color-accent-800)' : 'var(--color-surface)' }};color:{{ $mine ? 'var(--color-accent-100)' : 'var(--color-text)' }};border:1px solid var(--color-divider)">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px">
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px">
|
||||||
<div style="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} · {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}</div>
|
<div style="font-size:11px;opacity:0.65;margin-bottom:4px;display:flex;align-items:center;gap:4px">
|
||||||
|
@if ($m->source === 'email')
|
||||||
|
<span class="material-symbols-outlined" style="font-size:13px" title="Odebrane e-mailem">mail</span>
|
||||||
|
@endif
|
||||||
|
{{ $m->author_name }} · {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}
|
||||||
|
</div>
|
||||||
@if ($m->role === 'operator')
|
@if ($m->role === 'operator')
|
||||||
<div style="display:flex;gap:6px;flex:none">
|
<div style="display:flex;gap:6px;flex:none">
|
||||||
<span class="material-symbols-outlined" style="font-size:15px;cursor:pointer;opacity:0.7" wire:click="startEditMessage({{ $m->id }}, @js($m->body))">edit</span>
|
<span class="material-symbols-outlined" style="font-size:15px;cursor:pointer;opacity:0.7" wire:click="startEditMessage({{ $m->id }}, @js($m->body))">edit</span>
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ Artisan::command('inspire', function () {
|
|||||||
|
|
||||||
Schedule::command('tickets:check-sla-breaches')->everyFifteenMinutes();
|
Schedule::command('tickets:check-sla-breaches')->everyFifteenMinutes();
|
||||||
Schedule::command('automation:run-rules')->everyFifteenMinutes();
|
Schedule::command('automation:run-rules')->everyFifteenMinutes();
|
||||||
|
Schedule::command('emails:fetch-imap')->everyFiveMinutes()->withoutOverlapping();
|
||||||
|
|||||||
31
src/tests/Feature/DeletedTicketRedirectsInsteadOf404Test.php
Normal file
31
src/tests/Feature/DeletedTicketRedirectsInsteadOf404Test.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
test('visiting a deleted ticket as an operator redirects to the operator queue instead of 404ing', function () {
|
||||||
|
seedStatusesAndPriorities();
|
||||||
|
$operator = User::query()->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'));
|
||||||
|
});
|
||||||
203
src/tests/Feature/ImapCategoryRoutingAndSourceBadgeTest.php
Normal file
203
src/tests/Feature/ImapCategoryRoutingAndSourceBadgeTest.php
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Livewire\Admin\MailSettings;
|
||||||
|
use App\Livewire\Operator\Queue;
|
||||||
|
use App\Livewire\Operator\TicketShow;
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\ImapMailbox;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\TicketService;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
// ===================== TicketService::create() category-only routing =====================
|
||||||
|
|
||||||
|
test('create() sets category_id when only a category is given (no subcategory)', function () {
|
||||||
|
seedStatusesAndPriorities();
|
||||||
|
$category = Category::query()->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"');
|
||||||
|
});
|
||||||
215
src/tests/Feature/ImapMessageClassifierTest.php
Normal file
215
src/tests/Feature/ImapMessageClassifierTest.php
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Ldap\LldapUser;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\ImapMessageClassifier;
|
||||||
|
use App\Support\Imap\InboundEmail;
|
||||||
|
use App\Support\Settings;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use LdapRecord\Laravel\Testing\DirectoryEmulator;
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
DirectoryEmulator::tearDown();
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeInboundEmail(array $overrides = []): InboundEmail
|
||||||
|
{
|
||||||
|
return new InboundEmail(
|
||||||
|
fromEmail: $overrides['fromEmail'] ?? 'klient@example.com',
|
||||||
|
fromName: $overrides['fromName'] ?? 'Jan Kowalski',
|
||||||
|
subject: $overrides['subject'] ?? 'Zwykła wiadomość',
|
||||||
|
textBody: $overrides['textBody'] ?? 'Treść wiadomości.',
|
||||||
|
htmlBody: $overrides['htmlBody'] ?? '',
|
||||||
|
headers: $overrides['headers'] ?? [],
|
||||||
|
attachments: $overrides['attachments'] ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== rejectionReason() =====================
|
||||||
|
|
||||||
|
test('a normal reply is not rejected', function () {
|
||||||
|
$classifier = new ImapMessageClassifier;
|
||||||
|
|
||||||
|
expect($classifier->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();
|
||||||
|
});
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Livewire\Admin\Panel;
|
use App\Livewire\Admin\MailSettings;
|
||||||
use App\Models\EmailTemplate;
|
use App\Models\EmailTemplate;
|
||||||
use App\Notifications\TicketNotification;
|
use App\Notifications\TicketNotification;
|
||||||
use App\Providers\AppServiceProvider;
|
use App\Providers\AppServiceProvider;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
|
use Illuminate\Support\Facades\Config;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
test('admin can save the SMTP/from settings, and the password is only overwritten when provided', function () {
|
test('admin can save the SMTP/from settings, and the password is only overwritten when provided', function () {
|
||||||
$admin = adminUser();
|
$admin = adminUser();
|
||||||
|
|
||||||
Livewire::actingAs($admin)->test(Panel::class)
|
Livewire::actingAs($admin)->test(MailSettings::class)
|
||||||
->call('setTab', 'email')
|
|
||||||
->set('mailConfig.fromAddress', 'wsparcie@firma.pl')
|
->set('mailConfig.fromAddress', 'wsparcie@firma.pl')
|
||||||
->set('mailConfig.fromName', 'Zespół Wsparcia')
|
->set('mailConfig.fromName', 'Zespół Wsparcia')
|
||||||
->set('mailConfig.smtpEnabled', true)
|
->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');
|
->and(Settings::get('mail_smtp_password'))->toBe('sekret123');
|
||||||
|
|
||||||
// Saving again with a blank password field must not wipe the stored one.
|
// 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.smtpHost', 'smtp.firma.pl')
|
||||||
->set('mailConfig.smtpPassword', '')
|
->set('mailConfig.smtpPassword', '')
|
||||||
->call('saveMailConfig')
|
->call('saveMailConfig')
|
||||||
@@ -44,14 +44,14 @@ test('the SMTP test button reports an error without a host/from address, and suc
|
|||||||
Mail::fake();
|
Mail::fake();
|
||||||
$admin = adminUser();
|
$admin = adminUser();
|
||||||
|
|
||||||
Livewire::actingAs($admin)->test(Panel::class)
|
Livewire::actingAs($admin)->test(MailSettings::class)
|
||||||
->call('testMailConnection')
|
->call('testMailConnection')
|
||||||
->assertSet('mailTestResult', 'error');
|
->assertSet('mailTestResult', 'error');
|
||||||
|
|
||||||
// Mail::fake()'s raw() is a no-op that never throws, so a valid config
|
// 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
|
// reports success — this exercises the same config-override/restore path
|
||||||
// real sends use, without needing a reachable SMTP server in tests.
|
// 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.fromAddress', 'wsparcie@firma.pl')
|
||||||
->set('mailConfig.smtpHost', 'smtp.firma.pl')
|
->set('mailConfig.smtpHost', 'smtp.firma.pl')
|
||||||
->call('testMailConnection')
|
->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')
|
expect(config('mail.from.address'))->toBe('wsparcie@firma.pl')
|
||||||
->and(config('mail.from.name'))->toBe('Wsparcie');
|
->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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
39
src/tests/Feature/OperatorQueueSelectAllTest.php
Normal file
39
src/tests/Feature/OperatorQueueSelectAllTest.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Livewire\Operator\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
test('toggleSelectAll selects every currently visible ticket', function () {
|
||||||
|
seedStatusesAndPriorities();
|
||||||
|
$operator = operatorUser('select-all-1@example.com');
|
||||||
|
$a = makeTicket(['number' => '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]);
|
||||||
|
});
|
||||||
@@ -45,7 +45,12 @@ test('with obfuscation on, the ticket URL and the displayed number both use the
|
|||||||
->and($ticket->displayNumber())->toBe('#'.$ticket->checksum);
|
->and($ticket->displayNumber())->toBe('#'.$ticket->checksum);
|
||||||
|
|
||||||
$this->actingAs($operator)->get($url)->assertOk();
|
$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 () {
|
test('the API still binds tickets by numeric id regardless of the obfuscation setting', function () {
|
||||||
|
|||||||
61
src/tests/Feature/TicketServiceGuestReplyTest.php
Normal file
61
src/tests/Feature/TicketServiceGuestReplyTest.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\AutomationRule;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\TicketService;
|
||||||
|
|
||||||
|
test('guestReply records a client-role message with no author id', function () {
|
||||||
|
seedStatusesAndPriorities();
|
||||||
|
$ticket = makeTicket();
|
||||||
|
|
||||||
|
$message = app(TicketService::class)->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);
|
||||||
|
});
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
Panel administratora (`/admin`) to jedno miejsce do konfiguracji całego systemu:
|
Panel administratora (`/admin`) to jedno miejsce do konfiguracji całego systemu:
|
||||||
struktura zgłoszeń (kategorie, pola, statusy, priorytety, SLA), automatyzacje
|
struktura zgłoszeń (kategorie, pola, statusy, priorytety, SLA), automatyzacje
|
||||||
(reguły SLA, wyzwalacze), użytkownicy i zespoły, treści (szablony, szybkie
|
(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
|
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).
|
panelu Administratora przez menu profilu (prawy górny róg).
|
||||||
@@ -174,8 +175,58 @@ ważne + treść HTML).
|
|||||||
ustawienia.
|
ustawienia.
|
||||||
|
|
||||||
SMTP (host, port, szyfrowanie, użytkownik/hasło, adres/nazwa nadawcy, z
|
SMTP (host, port, szyfrowanie, użytkownik/hasło, adres/nazwa nadawcy, z
|
||||||
przyciskiem **„Testuj połączenie”**) konfiguruje się w zakładce **E-MAIL**,
|
przyciskiem **„Testuj połączenie”**) konfiguruje się w zakładce **Poczta**,
|
||||||
razem z layoutem/stopką wiadomości — patrz sekcja wyżej.
|
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
|
## Integracje
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,13 @@ swoje.
|
|||||||
|
|
||||||
**Akcje zbiorcze**: zaznacz kilka zgłoszeń checkboxami, by je **scalić** (pierwsze
|
**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
|
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/
|
Kolejka aktualizuje się **na żywo** — nowe zgłoszenie, zmiana statusu/priorytetu/
|
||||||
przypisania czy nowa odpowiedź pojawiają się bez odświeżania strony. Obok
|
przypisania czy nowa odpowiedź pojawiają się bez odświeżania strony. Obok
|
||||||
|
|||||||
Reference in New Issue
Block a user