Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d116dfd98 |
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