1 Commits

Author SHA1 Message Date
0d116dfd98 v1.2.0
- E-mail intake (IMAP), optional and off by default: clients can create a
  ticket or reply to an existing one just by sending/replying to an e-mail.
  Configure any number of mailboxes in the new Admin > Poczta page (SMTP +
  IMAP together, replacing the old "E-MAIL" tab), each routed to a specific
  subcategory or a whole category (new tickets.category_id column). Replies
  are matched to their ticket via the number/checksum already in every
  notification subject; autoresponders/bounces are detected and rejected;
  "restrict tickets to LDAP" is enforced for e-mail like the guest web form.
  Manual "Pobierz teraz" per-mailbox fetch button; dedicated
  storage/logs/imap-*.log regardless of the app's log level; mail-icon badges
  on e-mail-originated tickets/messages in the operator queue and ticket view.
- Operator queue: "select all" checkbox in the table header for every
  currently visible ticket under the active filter/tab.
- Fixed: scheduled commands (SLA breach check, automation rules, and now IMAP
  fetch) always sent notifications through .env's default mailer instead of
  the configured SMTP server, because AppServiceProvider's Settings override
  used to skip itself for any console command, not just migrate.
- Fixed: visiting a ticket that no longer exists (deleted mid-session, or a
  stale background refresh) showed a raw 404 instead of redirecting back to
  the operator queue / client dashboard.
- Docs: README/ARCHITECTURE/CLAUDE/install/wiki updated for all of the above,
  including the previously-missing host crontab entry for schedule:run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 12:44:30 +02:00
38 changed files with 2290 additions and 164 deletions

View File

@@ -101,6 +101,18 @@ The `{numer}` placeholder available in admin-editable e-mail templates
own `#{numer}`, so adding the prefix there too would double it up or clash
with a non-default prefix.
A ticket route binding that resolves to nothing (most commonly: the ticket
was deleted while someone had it open, and a later request — typically
Livewire's own "model missing during hydration" recovery, which does a full
`window.location.reload()` of the same page — hits `{ticket}` again) no
longer surfaces Laravel's default 404 page. `bootstrap/app.php` registers a
`NotFoundHttpException` render callback (note: `Handler::prepareException()`
already converts `ModelNotFoundException` into `NotFoundHttpException`,
wrapped as `getPrevious()`, *before* any render callback runs — a callback
typed against `ModelNotFoundException` itself would never match) that
redirects to `operator.queue`/`client.dashboard` instead, for any
authenticated request under `operator/*`/`client/*`.
## Roles & permissions
`$user->roles` reads/writes as a plain array (`['client', 'operator']`), but
@@ -138,7 +150,7 @@ attributes.
over the `settings` table, with hardcoded defaults for every key (company name,
LDAP/SMTP connection details, attachment limits, session lifetime, timezone,
branding/email HTML, etc.). Admin > Konfiguracja (general/attachments/session),
E-MAIL (SMTP) and Integracje (LDAP, BookStack) all write to this same table, and
Poczta (SMTP) and Integracje (LDAP, BookStack) all write to this same table, and
`AppServiceProvider::boot()` re-applies the relevant subset of it over
`config()` on every request — meaning **`Setting` rows win over `.env`** for
LDAP, mail, session lifetime and timezone once they're non-empty. This is by
@@ -147,6 +159,19 @@ source of the "seeded placeholder overrides real `.env` values" gotcha
documented in [install.md](install.md) — anything touching LDAP/mail/session/
timezone config should go through `Settings`, not raw `config()`/`.env` reads.
`settingsTableUsable()` gates all four overrides on whether the `settings`
table is safe to query yet — but is deliberately scoped to just the `migrate`
command family (`runningConsoleCommand('migrate', 'migrate:fresh', ...)`), not
"any console command". It used to blanket-skip for every console invocation
(exempting only unit tests), which silently broke every scheduled command's
outbound mail: `AppServiceProvider::boot()` runs on each process including
`schedule:run`-invoked commands, so `tickets:check-sla-breaches`,
`automation:run-rules` and `emails:fetch-imap` (below) all sent notifications
through whatever `.env`'s `MAIL_MAILER` happened to be (`log`, i.e. nowhere)
instead of the admin-configured SMTP server — with no error, since the `log`
mailer never throws. If a scheduled command's notification/lookup ever again
seems to silently use `.env` defaults instead of `Settings`, check here first.
## Notifications
`TicketService::notify(Ticket $ticket, string $triggerKey)` is the single
@@ -276,6 +301,86 @@ that closes the ticket doesn't block earlier-ordered rules already applied
this run, but a later rule's own query naturally excludes an already-closed
ticket.
## IMAP e-mail intake
Optional, off by default (`ImapMailbox.enabled` per row — there is no single
global toggle since this is a list of N mailboxes, not a `Settings`
singleton). Split across three layers, mirroring the plan that shipped it:
- **`App\Models\ImapMailbox`** — one row per polled mailbox (host/port/
encryption/username, `password` cast `'encrypted'` — the first model in
this codebase to use Laravel's native encrypted cast rather than the
manual `Crypt::` pattern `Settings` uses, since this is a list of records
rather than key/value config). `default_subcategory_id` XOR
`default_category_id` (enforced by the admin form's single combined
selector, not a DB constraint) route new tickets; `category_id` only ever
gets populated when there's no subcategory to derive one from (see
`Ticket::categoryLabel()`/`TicketService::create()`).
- **`App\Services\ImapMessageClassifier`** — pure decision logic, no IMAP
connection, fully Pest-testable: `rejectionReason()` (auto-reply/bounce
detection via `Auto-Submitted`/`Precedence`/`X-Autoreply` headers + EN/PL
subject phrases + a per-mailbox sender blocklist), `matchTicket()`
(extracts every digit run ≥4 chars from the subject — after stripping
`Re:`/`Odp:`/`Fwd:`/`FW:`/`Aw:` — and tries each through
`Ticket::resolveRouteBinding()`, so it transparently matches either the
plain sequential number or the obfuscated checksum, whichever mode is
active; no changes to outbound mail were needed since every notification
subject already carries `{numer}`), `isSenderAllowed()` (mirrors
`Landing::emailIsKnown()` — enforces `restrict_tickets_to_ldap` for e-mail
exactly like the guest web form), `resolveSender()` (existing local user,
or `LdapUserProvisioner::findOrCreateByEmail()` if enabled).
- **`App\Services\ImapMailboxFetcher`** — the I/O layer (`webklex/php-imap`,
a pure-PHP IMAP client with no `ext-imap` dependency — confirmed available
extensions were sufficient, no Dockerfile change needed). Fetches
`whereUnseen()` per mailbox, flags/moves a message **before** creating the
ticket (a crash mid-batch then risks a "processed but no ticket" message —
visible and easy to fix manually — rather than a duplicate ticket on the
next run), converts attachments to `UploadedFile` via a temp file (`$test
= true` bypasses the `is_uploaded_file()` check outside a real HTTP
request) so they flow through the existing `Settings::validateAttachments()`
+ `TicketService::attachFiles()` unchanged. Logs every connection attempt
and per-message decision to a dedicated `imap` log channel
(`storage/logs/imap-*.log`, always `debug` level regardless of the app's
own `LOG_LEVEL` — see `config/logging.php`) since this app commonly runs
at `LOG_LEVEL=error`, which would otherwise silently swallow this
activity entirely.
- One real bug worth remembering if IMAP rejection logic ever seems too
aggressive again: Webklex's `Header::get($name)` returns an *empty*
`Attribute` (not `null`) for a header that isn't present at all, and
`Attribute::first()` on that empty instance is `''`, not `null` — a
naive `$header !== null` check therefore treats *every* message as
carrying *every* header. Guarded in two places: `ImapMailboxFetcher`
only keeps a header value that's non-empty, and
`InboundEmail::header()` itself also treats `''` as absent, so the bug
can't resurface even if some other header source stops filtering.
- **`TicketService::guestReply()`** — the one new method added to the
existing service: a customer reply with no `User` account (mirrors
`clientReply()` — real customer activity, resets SLA silence, fires
`comment_added` so an admin-configured Trigger can reopen a closed ticket
— rather than `apiMessage()`, which tags a system/integration note, not
client content). Both `clientReply()` and `guestReply()` take an optional
trailing `string $source = 'web'`, stored as `TicketMessage.source`
(`null` for `'web'`) — the per-message counterpart to `Ticket.source`,
since a ticket opened on the web can later get an e-mail reply or vice
versa. Both surface as a small mail-icon badge (operator queue: next to
the ticket number; ticket view: per-message in the thread, plus a tag next
to the ticket number in the header).
- **`emails:fetch-imap`** (`app/Console/Commands/FetchImapEmails.php`),
registered in `routes/console.php` as
`Schedule::command('emails:fetch-imap')->everyFiveMinutes()->withoutOverlapping()`
— the one scheduled command in this app that opts into
`withoutOverlapping()` (SLA/automation don't), given IMAP I/O latency.
Early-returns if no `ImapMailbox` is enabled. Also callable directly per
mailbox from Admin > Poczta's "Pobierz teraz" button
(`ImapMailboxFetcher::fetchMailbox()`, bypassing the enabled-only
`fetchAll()` used by the schedule) for on-demand fetching/diagnosis
without shell access.
Requires the same external `schedule:run` cron entry as SLA/automation (see
[install.md](install.md) and the crontab note in
[CLAUDE.md](CLAUDE.md)) — without it, only the manual "Pobierz teraz" button
does anything.
## API
`routes/api.php` + `app/Http/Controllers/Api/` expose a small ability-scoped REST

View File

@@ -3,6 +3,67 @@
All notable changes to this project are documented in this file. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [1.2.0] - 2026-07-23
### Added
- **E-mail intake (IMAP)**, optional and off by default — clients can create a
ticket or reply to an existing one just by sending/replying to an e-mail.
Configure any number of mailboxes in the new **Admin > Poczta** page (which
now hosts SMTP alongside IMAP, replacing the old "E-MAIL" tab), each with
its own host/port/encryption/credentials/folder and routed to either a
specific subcategory (routes to that subcategory's team, same as a web
ticket) or a whole category with no subcategory (a new `tickets.category_id`
column covers this case — previously a ticket's category only ever came
through a subcategory).
- A reply is matched back to its ticket via the number/checksum already
present in every notification e-mail's subject — works with either the
plain sequential number or the obfuscated checksum, whichever numbering
mode is active, no changes to outbound templates needed.
- Automatic replies (autoresponders, "out of office", bounces/mailer-daemon)
are detected via headers and common EN/PL subject phrasing and rejected
instead of creating a ticket; a per-mailbox sender blocklist covers the
rest. The "tylko użytkownicy z LDAP" restriction is enforced for e-mail
exactly like the guest web form.
- A "Pobierz teraz" button per mailbox fetches immediately, outside the
5-minute schedule — useful for testing a freshly-configured mailbox or
diagnosing why a specific e-mail didn't turn into a ticket.
- Every connection attempt and per-message decision (accepted/rejected/
matched to which ticket) is logged to a dedicated `storage/logs/imap-*.log`
file, independent of the app's own log level.
- Tickets and individual messages that came in by e-mail show a small
mail-icon badge in the operator queue and ticket view, distinguishing them
from ones created/replied to on the web.
- **Operator queue**: a "select all" checkbox in the table header
selects/deselects every ticket currently visible under the active
filter/tab in one click, instead of clicking each row's checkbox.
### Changed
- Admin's old **"E-MAIL"** tab is now **"Poczta"** and also lists/manages the
IMAP mailboxes above — the two halves of "reply by e-mail" (send/receive)
now live together instead of SMTP being off on its own.
### Fixed
- **Scheduled-command notifications were silently going nowhere.**
`AppServiceProvider`'s Settings-based config override (SMTP/LDAP/session/
timezone) used to skip itself for *any* console command, not just
`migrate` — meaning `tickets:check-sla-breaches` and `automation:run-rules`
(and now `emails:fetch-imap`) always sent their e-mails through whatever
`.env`'s `MAIL_MAILER` happened to be (`log`, i.e. nowhere) instead of the
admin-configured SMTP server, with no visible error. Now scoped to just the
`migrate` command family, so every scheduled command gets the same live
config a web request would.
- Visiting a ticket that no longer exists (most commonly: it was deleted
while the viewer had it open, and a later background refresh hit the same
URL) no longer shows Laravel's default 404 page — redirects back to the
operator queue or client dashboard instead.
- This host had no crontab entry at all for `php artisan schedule:run`
meaning SLA breach checks and automation rules had never actually run on
their own, only ever on request. Documented and configured (see
[CLAUDE.md](CLAUDE.md)).
## [1.1.4] - 2026-07-23
### Added

View File

@@ -60,6 +60,30 @@ with no rebuild or restart:
view:clear` to flush any root-owned compiled views before ending the
session — don't wait for a report of a broken page to catch it.
## Scheduled commands need a host crontab entry
The Docker image ships no cron/supervisor of its own (see [install.md](install.md)),
so `tickets:check-sla-breaches`, `automation:run-rules`, and `emails:fetch-imap`
(all registered in `routes/console.php` via `Schedule::command(...)`) only ever
run if something outside the container calls `php artisan schedule:run` on a
timer. **As of 2026-07-23 this is configured** — root's crontab on the host
runs, every minute:
```cron
* * * * * cd /mnt/rabbit-containers/servicedesk && docker compose exec -T servicedesk php artisan schedule:run >> /dev/null 2>&1
```
(`sudo crontab -l -u root` to inspect/edit — it previously did not exist at all,
which meant none of the three scheduled commands above had ever run
automatically; ask before changing this again, since removing it silently
breaks SLA checks, automation rules and IMAP fetching, and confusingly not the
IMAP feature alone if you're only debugging that one.) IMAP-specific activity
(connect attempts, per-message accept/reject decisions, created/replied ticket
ids) is logged separately from the app's normal `LOG_LEVEL` to
`storage/logs/imap-*.log` (see the `imap` channel in `config/logging.php`) —
check there first when a mailbox isn't behaving as expected, before assuming
the scheduler itself isn't firing.
## Apache `/icons/` alias trap
The stock `php:apache` image enables `mods-enabled/alias.conf`, which defines

View File

@@ -99,6 +99,18 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
- **Attachments** — drag-and-drop upload (in addition to the file picker); every
attachment shows in the message thread as just its filename, opening in a new
tab on click (no inline image preview).
- **E-mail intake (IMAP)** *(optional, off by default)* — clients can create
tickets or reply to an existing one just by sending/replying to an e-mail;
configure any number of mailboxes in Admin > Poczta (e.g. one address per
team), each routed to a specific subcategory or a whole category. A reply
is matched back to its ticket via the number/checksum already present in
every notification's subject; automatic replies (autoresponders, bounces)
are detected and rejected instead of creating junk tickets, and the
"restrict tickets to LDAP" setting is enforced for e-mail exactly like the
guest web form. A manual "Pobierz teraz" button fetches immediately
outside the 5-minute schedule; all activity is logged separately to
`storage/logs/imap-*.log`. Tickets/messages that came in by e-mail show a
small mail-icon badge in the operator queue and ticket view.
- **Configurable ticket numbering** (Admin > Konfiguracja) — a custom prefix and
minimum zero-padded length for the ticket number, plus an optional "hide
ticket order" mode that displays a stable per-ticket checksum instead of the
@@ -131,7 +143,8 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
- **Backend**: Laravel, Livewire (server-driven UI, no SPA build beyond Tailwind/Vite
for CSS), LdapRecord for directory auth, Sanctum for API tokens, L5-Swagger for
API docs, Laravel Reverb for WebSocket broadcasting (real-time queue/chat
updates — see [ARCHITECTURE.md](ARCHITECTURE.md)).
updates — see [ARCHITECTURE.md](ARCHITECTURE.md)), webklex/php-imap for the
optional e-mail intake fetcher (pure-PHP IMAP client, no `ext-imap` needed).
- **Frontend**: Blade + Livewire + a little Alpine.js for local UI state; Tailwind
v4 via Vite for `resources/css/app.css`; Laravel Echo + Pusher-protocol client
(`resources/js/echo.js`) for Reverb. No JS charting library — the statistics
@@ -179,8 +192,9 @@ src/ Laravel application
app/Livewire/ Client/Operator/Admin Livewire components
app/Models/ Eloquent models
app/Events/ Broadcast events (TicketQueueChanged, TicketMessagePosted)
app/Console/Commands/ Scheduled commands (SLA breach check, automation rules)
app/Services/ TicketService (ticket lifecycle + notifications), BookStackClient
app/Console/Commands/ Scheduled commands (SLA breach check, automation rules, IMAP fetch)
app/Services/ TicketService (ticket lifecycle + notifications), BookStackClient,
ImapMailboxFetcher (I/O) + ImapMessageClassifier (pure logic)
app/Ldap/ LDAP user model + sync handlers
database/migrations/ Schema (one file per table group, final shape)
database/seeders/ DatabaseSeeder — reference data, no ticket data

View File

@@ -265,11 +265,14 @@ docker run --rm -v "$(pwd)/src":/app -w /app node:22 npm run build
Powtarzaj drugi krok po każdej zmianie w `resources/css/` lub `resources/js/`.
### 1.6. Zadanie cykliczne (SLA) i kolejka
### 1.6. Zadanie cykliczne (SLA, automatyzacje, poczta IMAP) i kolejka
`routes/console.php` planuje `tickets:check-sla-breaches` co 15 minut, ale **obraz
Dockera nie ma wbudowanego cron/supervisora** — bez dodatkowego kroku to zadanie
nigdy się nie uruchomi. Najprościej dodać wpis crona **na hoście**:
`routes/console.php` planuje `tickets:check-sla-breaches` i `automation:run-rules`
co 15 minut oraz `emails:fetch-imap` (odbieranie zgłoszeń/odpowiedzi e-mailem —
patrz Admin > Poczta) co 5 minut, ale **obraz Dockera nie ma wbudowanego
cron/supervisora** — bez dodatkowego kroku żadne z tych zadań nigdy się nie
uruchomi (poczta IMAP nadal da się sprawdzić ręcznie przyciskiem „Pobierz teraz”,
ale bez crona nic nie dzieje się samo). Najprościej dodać wpis crona **na hoście**:
```cron
* * * * * cd /ścieżka/do/repo && docker compose exec -T servicedesk php artisan schedule:run >> /dev/null 2>&1
@@ -457,9 +460,10 @@ server {
}
```
### 2.6. Zadanie cykliczne (SLA) i kolejka
### 2.6. Zadanie cykliczne (SLA, automatyzacje, poczta IMAP) i kolejka
Crontab użytkownika, pod którym stoi aplikacja (np. `www-data`):
Crontab użytkownika, pod którym stoi aplikacja (np. `www-data`) — obsługuje też
`automation:run-rules` i `emails:fetch-imap` (patrz 1.6 wyżej):
```cron
* * * * * cd /var/www/servicedesk/src && php artisan schedule:run >> /dev/null 2>&1

View 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;
}
}

View 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');
}
}

View File

@@ -22,8 +22,6 @@ use App\Services\LdapUserProvisioner;
use App\Support\Settings;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use LdapRecord\Connection;
use Livewire\Attributes\Computed;
@@ -148,10 +146,6 @@ class Panel extends Component
public ?string $ldapTestResult = null;
public array $mailConfig = [];
public ?string $mailTestResult = null;
public array $bookstackConfig = [];
public ?string $bookstackTestResult = null;
@@ -201,17 +195,6 @@ class Panel extends Component
'restrictTicketsToLdap' => Settings::bool('restrict_tickets_to_ldap'),
];
$this->mailConfig = [
'smtpEnabled' => Settings::bool('mail_smtp_enabled'),
'smtpHost' => Settings::get('mail_smtp_host'),
'smtpPort' => Settings::get('mail_smtp_port'),
'smtpUsername' => Settings::get('mail_smtp_username'),
'smtpPassword' => Settings::get('mail_smtp_password'),
'smtpEncryption' => Settings::get('mail_smtp_encryption'),
'fromAddress' => Settings::get('mail_from_address'),
'fromName' => Settings::get('mail_from_name'),
];
$this->bookstackConfig = [
'enabled' => Settings::bool('bookstack_enabled'),
'baseUrl' => Settings::get('bookstack_base_url'),
@@ -1525,75 +1508,6 @@ class Panel extends Component
$this->bookstackTestMessage = $result['message'];
}
// ===================== MAIL / SMTP CONFIG =====================
public function saveMailConfig(): void
{
Settings::set('mail_smtp_enabled', $this->mailConfig['smtpEnabled'] ? '1' : '0');
Settings::set('mail_smtp_host', $this->mailConfig['smtpHost']);
Settings::set('mail_smtp_port', (string) $this->mailConfig['smtpPort']);
Settings::set('mail_smtp_username', $this->mailConfig['smtpUsername']);
if ($this->mailConfig['smtpPassword']) {
Settings::set('mail_smtp_password', $this->mailConfig['smtpPassword']);
}
Settings::set('mail_smtp_encryption', $this->mailConfig['smtpEncryption']);
Settings::set('mail_from_address', $this->mailConfig['fromAddress']);
Settings::set('mail_from_name', $this->mailConfig['fromName']);
$this->mailTestResult = null;
}
/**
* Sends a real test e-mail to the logged-in admin using the form's
* current (unsaved) values, temporarily overriding the mail config the
* same way AppServiceProvider does for real once saved so this test
* exercises the exact path production notifications will use.
*/
public function testMailConnection(): void
{
$cfg = $this->mailConfig;
if (empty($cfg['smtpHost']) || empty($cfg['fromAddress'])) {
$this->mailTestResult = 'error';
return;
}
$original = Config::get('mail');
try {
Config::set('mail.default', 'smtp');
Config::set('mail.mailers.smtp.host', $cfg['smtpHost']);
Config::set('mail.mailers.smtp.port', (int) $cfg['smtpPort']);
Config::set('mail.mailers.smtp.username', $cfg['smtpUsername'] ?: null);
Config::set('mail.mailers.smtp.password', $cfg['smtpPassword'] ?: Settings::get('mail_smtp_password'));
Config::set('mail.mailers.smtp.scheme', match ($cfg['smtpEncryption']) {
'ssl' => 'smtps',
'tls' => 'smtp',
default => null,
});
Config::set('mail.from.address', $cfg['fromAddress']);
Config::set('mail.from.name', $cfg['fromName'] ?: Settings::get('company_name'));
app()->forgetInstance('mail.manager');
app()->forgetInstance('mailer');
Mail::raw('To jest testowa wiadomość wysłana z panelu administratora Servicedesk.', function ($message) {
$message->to(Auth::user()->email)->subject('Test konfiguracji SMTP');
});
$this->mailTestResult = 'ok';
} catch (\Throwable) {
$this->mailTestResult = 'error';
} finally {
Config::set('mail', $original);
app()->forgetInstance('mail.manager');
app()->forgetInstance('mailer');
}
}
// ===================== GENERIC DELETE CONFIRM =====================
public function requestDelete(string $type, mixed $id, string $message): void

View File

@@ -298,7 +298,13 @@ class Queue extends Component
$query->where('priority_key', $this->filterPriority);
}
if ($this->filterCategory !== 'all') {
$query->whereHas('subcategory', fn ($q) => $q->where('category_id', $this->filterCategory));
// A ticket carries a category either via its subcategory or,
// when routed to a whole category with no subcategory (e.g. an
// IMAP mailbox), directly on tickets.category_id.
$query->where(function ($q) {
$q->whereHas('subcategory', fn ($sq) => $sq->where('category_id', $this->filterCategory))
->orWhere('category_id', $this->filterCategory);
});
}
if ($this->filterCustomerId) {
$query->where('customer_id', $this->filterCustomerId);
@@ -307,7 +313,7 @@ class Queue extends Component
$query->search($this->search);
}
$tickets = $query->with(['subcategory.category', 'assignee', 'priority', 'status', 'team'])->get();
$tickets = $query->with(['subcategory.category', 'category', 'assignee', 'priority', 'status', 'team'])->get();
return $this->sortTickets($tickets);
}
@@ -425,6 +431,20 @@ class Queue extends Component
}
}
/**
* Selects every ticket currently visible under the active filters/queue
* (not every ticket in the system) toggles off if all of them are
* already selected, matching the usual "header checkbox" convention.
*/
public function toggleSelectAll(): void
{
$visibleIds = $this->filteredTickets->pluck('id')->all();
$this->selectedIds = empty(array_diff($visibleIds, $this->selectedIds))
? array_values(array_diff($this->selectedIds, $visibleIds))
: array_values(array_unique(array_merge($this->selectedIds, $visibleIds)));
}
public function mergeSelected(): void
{
$ids = $this->selectedIdsInScope();

View 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 '—';
}
}

View File

@@ -13,8 +13,8 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
#[Fillable([
'number', 'checksum', 'customer_id', 'email', 'name', 'subcategory_id', 'subject', 'body',
'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id',
'number', 'checksum', 'customer_id', 'email', 'name', 'subcategory_id', 'category_id', 'subject', 'body',
'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id', 'source',
'sla_notified_at', 'last_customer_activity_at', 'time_spent_seconds', 'timer_started_at',
'created_at', 'updated_at', 'csat_rating', 'csat_comment', 'csat_rated_at',
])]
@@ -72,6 +72,16 @@ class Ticket extends Model
return $this->belongsTo(Subcategory::class);
}
/**
* Only ever set when there's no subcategory to derive a category from
* (subcategory_id already implies one via Subcategory::category()) see
* categoryLabel() and the migration that introduced this column.
*/
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function watchers(): BelongsToMany
{
return $this->belongsToMany(User::class, 'ticket_watchers');
@@ -214,7 +224,7 @@ class Ticket extends Model
public function categoryLabel(): string
{
return $this->subcategory?->label() ?? '';
return $this->subcategory?->label() ?? $this->category?->name ?? '';
}
/**

View File

@@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
#[Fillable(['ticket_id', 'author_name', 'internal', 'body', 'edited', 'api_client_id', 'created_at', 'updated_at'])]
#[Fillable(['ticket_id', 'author_name', 'internal', 'body', 'edited', 'api_client_id', 'source', 'created_at', 'updated_at'])]
class TicketMessage extends Model
{
protected function casts(): array

View File

@@ -85,13 +85,21 @@ class AppServiceProvider extends ServiceProvider
}
/**
* Avoid touching the DB during artisan commands that run before the
* `settings` table exists (e.g. `migrate` itself), or before it can be
* queried at all shared by every settings-driven config override below.
* Avoid touching the DB during the specific artisan commands that run
* before the `settings` table exists or could be mid-schema-change (the
* migrate family) shared by every settings-driven config override
* below. Deliberately scoped to just those commands rather than "any
* console command": scheduled commands (`schedule:run` → e.g.
* `emails:fetch-imap`, `tickets:check-sla-breaches`) also run in the
* console and need the real SMTP/LDAP/timezone overrides exactly like a
* web request does, or their notifications/lookups silently fall back
* to whatever's in `.env` (this was a real bug: scheduled-command
* notifications were always going out via the `.env` `log` mailer
* instead of the configured SMTP server).
*/
protected function settingsTableUsable(): bool
{
if ($this->app->runningInConsole() && ! $this->app->runningUnitTests()) {
if ($this->app->runningConsoleCommand('migrate', 'migrate:fresh', 'migrate:refresh', 'migrate:reset', 'migrate:rollback', 'migrate:install')) {
return false;
}

View 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;
}
}

View 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;
}
}

View File

@@ -42,6 +42,10 @@ class TicketService
'email' => $customer?->email ?? $data['email'],
'name' => $customer?->name ?? ($data['name'] ?? $data['email']),
'subcategory_id' => $subcategory?->id,
// category_id only ever carries a value when there's no
// subcategory to derive one from (e.g. an IMAP mailbox routed to
// a whole category rather than a specific subcategory).
'category_id' => $subcategory ? null : ($data['category_id'] ?? null),
'subject' => $data['subject'],
'body' => $data['body'],
'status_key' => Settings::get('default_status', 'new'),
@@ -50,6 +54,7 @@ class TicketService
'assignee_id' => $data['assignee_id'] ?? null,
'custom_fields' => $data['custom_values'] ?? [],
'last_customer_activity_at' => now(),
'source' => $data['source'] ?? 'web',
]);
$message = $ticket->messages()->create([
@@ -213,11 +218,12 @@ class TicketService
TicketMessagePosted::dispatch($ticket->id, $message->id, true, $operator->id);
}
public function clientReply(Ticket $ticket, User $client, string $body, array $attachments = []): void
public function clientReply(Ticket $ticket, User $client, string $body, array $attachments = [], string $source = 'web'): void
{
$message = $ticket->messages()->create([
'author_name' => $client->name,
'body' => $body,
'source' => $source === 'web' ? null : $source,
]);
$message->attachAuthor($client->id, 'client');
$ticket->touch();
@@ -238,6 +244,36 @@ class TicketService
TicketQueueChanged::dispatch($ticket->id, 'message_posted', $client->id);
}
/**
* A reply from a customer with no User account e.g. an e-mail reply
* from an address the IMAP fetcher couldn't resolve to a local/LDAP
* user. Mirrors clientReply() (real customer activity: resets SLA
* silence, fires comment_added so an admin-configured Trigger can reopen
* a closed ticket) rather than apiMessage() (attachAuthor(null, null)
* a system/integration note, not client content). attachAuthor(null,
* 'client') matches how create() already tags a guest's opening message.
*/
public function guestReply(Ticket $ticket, string $authorName, string $body, array $attachments = [], string $source = 'web'): TicketMessage
{
$message = $ticket->messages()->create([
'author_name' => $authorName,
'body' => $body,
'source' => $source === 'web' ? null : $source,
]);
$message->attachAuthor(null, 'client');
$ticket->touch();
$this->attachFiles($ticket, $message, $attachments);
$ticket->update(['last_customer_activity_at' => now()]);
$ticket->automationRuleLogs()->delete();
app(TriggerEngine::class)->handle($ticket, 'comment_added');
TicketMessagePosted::dispatch($ticket->id, $message->id, false, null);
TicketQueueChanged::dispatch($ticket->id, 'message_posted', null);
return $message;
}
public function toggleWatch(Ticket $ticket, User $user): bool
{
if ($ticket->isWatchedBy($user)) {

View 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)));
}
}

View File

@@ -1,12 +1,14 @@
<?php
use App\Http\Middleware\EnsureRole;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
@@ -44,4 +46,29 @@ return Application::configure(basePath: dirname(__DIR__))
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*'),
);
// A ticket deleted mid-session (typically by the operator/client
// currently viewing it) leaves any later request for that same
// {ticket} route binding 404ing — most commonly Livewire's own
// "model missing during hydration" recovery, which does a full
// window.location.reload() of the very page whose ticket just
// disappeared (e.g. the ticket-show view's periodic fallback
// refresh polling a few seconds after a delete+redirect). Land back
// on that area's own list page instead of a raw 404.
//
// Handler::prepareException() already converts ModelNotFoundException
// into NotFoundHttpException (wrapping the original as getPrevious())
// before any render() callback is dispatched — a callback typed
// against ModelNotFoundException itself would simply never match.
$exceptions->render(function (NotFoundHttpException $e, Request $request) {
if (! $e->getPrevious() instanceof ModelNotFoundException || ! $request->user()) {
return null;
}
return match (true) {
$request->is('operator/*') => redirect()->route('operator.queue'),
$request->is('client/*') => redirect()->route('client.dashboard'),
default => null,
};
});
})->create();

View File

@@ -16,7 +16,8 @@
"laravel/reverb": "*",
"laravel/sanctum": "*",
"laravel/tinker": "^3.0",
"livewire/livewire": "*"
"livewire/livewire": "*",
"webklex/php-imap": "*"
},
"require-dev": {
"fakerphp/faker": "^1.23",

83
src/composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "321add40614eb8751e0c8dbda55016eb",
"content-hash": "abe8bd31e8d8849ae593e562f73a39df",
"packages": [
{
"name": "brick/math",
@@ -7593,6 +7593,87 @@
],
"time": "2026-04-26T05:33:54+00:00"
},
{
"name": "webklex/php-imap",
"version": "6.2.0",
"source": {
"type": "git",
"url": "https://github.com/Webklex/php-imap.git",
"reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Webklex/php-imap/zipball/6b8ef85d621bbbaf52741b00cca8e9237e2b2e05",
"reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05",
"shasum": ""
},
"require": {
"ext-fileinfo": "*",
"ext-iconv": "*",
"ext-json": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-openssl": "*",
"ext-zip": "*",
"illuminate/pagination": ">=5.0.0",
"nesbot/carbon": "^2.62.1|^3.2.4",
"php": "^8.0.2",
"symfony/http-foundation": ">=2.8.0"
},
"require-dev": {
"phpunit/phpunit": "^9.5.10"
},
"suggest": {
"symfony/mime": "Recomended for better extension support",
"symfony/var-dumper": "Usefull tool for debugging"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "6.0-dev"
}
},
"autoload": {
"psr-4": {
"Webklex\\PHPIMAP\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Malte Goldenbaum",
"email": "github@webklex.com",
"role": "Developer"
}
],
"description": "PHP IMAP client",
"homepage": "https://github.com/webklex/php-imap",
"keywords": [
"imap",
"mail",
"php-imap",
"pop3",
"webklex"
],
"support": {
"issues": "https://github.com/Webklex/php-imap/issues",
"source": "https://github.com/Webklex/php-imap/tree/6.2.0"
},
"funding": [
{
"url": "https://www.buymeacoffee.com/webklex",
"type": "custom"
},
{
"url": "https://ko-fi.com/webklex",
"type": "ko_fi"
}
],
"time": "2025-04-25T06:02:37+00:00"
},
{
"name": "zircote/swagger-php",
"version": "6.4.0",

View File

@@ -73,6 +73,19 @@ return [
'replace_placeholders' => true,
],
// Dedicated, always-verbose channel for the IMAP fetcher
// (emails:fetch-imap) — kept separate from 'single'/LOG_LEVEL so a
// production app typically running at LOG_LEVEL=error still gets
// full visibility into what the fetcher did on every run, without
// that verbosity going into the main laravel.log.
'imap' => [
'driver' => 'daily',
'path' => storage_path('logs/imap.log'),
'level' => 'debug',
'days' => 14,
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),

View File

@@ -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');
}
};

View File

@@ -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');
});
}
};

View File

@@ -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');
});
}
};

View 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) 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>

View File

@@ -17,7 +17,7 @@ $tabGroups = [
],
'Ustawienia' => [
['key' => 'templates', 'label' => 'Szablony e-mail', 'icon' => 'mail'],
['key' => 'email', 'label' => 'E-MAIL', 'icon' => 'forward_to_inbox'],
['key' => 'email', 'label' => 'Poczta', 'icon' => 'forward_to_inbox'],
['key' => 'branding', 'label' => 'Wygląd i branding', 'icon' => 'palette'],
['key' => 'config', 'label' => 'Konfiguracja', 'icon' => 'settings'],
['key' => 'integrations', 'label' => 'Integracje', 'icon' => 'hub'],
@@ -483,45 +483,7 @@ $tabGroups = [
</div>
</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">
<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>
<livewire:admin.mail-settings />
@endif
{{-- ================= BRANDING ================= --}}

View File

@@ -137,7 +137,7 @@
<table class="table table-cards-mobile">
<thead>
<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)
@continue(! in_array($key, $visibleColumns))
<th>
@@ -161,7 +161,12 @@
<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>
@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
@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>

View File

@@ -36,7 +36,14 @@
<div class="main-col" style="display:flex;flex-direction:column;gap:16px">
<div class="card" style="padding:20px;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)
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="toggleEditDetails">Edytuj</button>
@endunless
@@ -160,7 +167,12 @@
<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="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 }} &middot; {{ \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 }} &middot; {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}
</div>
@if ($m->role === 'operator')
<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>

View File

@@ -10,3 +10,4 @@ Artisan::command('inspire', function () {
Schedule::command('tickets:check-sla-breaches')->everyFifteenMinutes();
Schedule::command('automation:run-rules')->everyFifteenMinutes();
Schedule::command('emails:fetch-imap')->everyFiveMinutes()->withoutOverlapping();

View 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'));
});

View 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"');
});

View 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();
});

View File

@@ -1,18 +1,18 @@
<?php
use App\Livewire\Admin\Panel;
use App\Livewire\Admin\MailSettings;
use App\Models\EmailTemplate;
use App\Notifications\TicketNotification;
use App\Providers\AppServiceProvider;
use App\Support\Settings;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Mail;
use Livewire\Livewire;
test('admin can save the SMTP/from settings, and the password is only overwritten when provided', function () {
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class)
->call('setTab', 'email')
Livewire::actingAs($admin)->test(MailSettings::class)
->set('mailConfig.fromAddress', 'wsparcie@firma.pl')
->set('mailConfig.fromName', 'Zespół Wsparcia')
->set('mailConfig.smtpEnabled', true)
@@ -31,7 +31,7 @@ test('admin can save the SMTP/from settings, and the password is only overwritte
->and(Settings::get('mail_smtp_password'))->toBe('sekret123');
// Saving again with a blank password field must not wipe the stored one.
Livewire::actingAs($admin)->test(Panel::class)
Livewire::actingAs($admin)->test(MailSettings::class)
->set('mailConfig.smtpHost', 'smtp.firma.pl')
->set('mailConfig.smtpPassword', '')
->call('saveMailConfig')
@@ -44,14 +44,14 @@ test('the SMTP test button reports an error without a host/from address, and suc
Mail::fake();
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class)
Livewire::actingAs($admin)->test(MailSettings::class)
->call('testMailConnection')
->assertSet('mailTestResult', 'error');
// Mail::fake()'s raw() is a no-op that never throws, so a valid config
// reports success — this exercises the same config-override/restore path
// real sends use, without needing a reachable SMTP server in tests.
Livewire::actingAs($admin)->test(Panel::class)
Livewire::actingAs($admin)->test(MailSettings::class)
->set('mailConfig.fromAddress', 'wsparcie@firma.pl')
->set('mailConfig.smtpHost', 'smtp.firma.pl')
->call('testMailConnection')
@@ -109,3 +109,30 @@ test('AppServiceProvider always applies the from-address override regardless of
expect(config('mail.from.address'))->toBe('wsparcie@firma.pl')
->and(config('mail.from.name'))->toBe('Wsparcie');
});
test('regression: the mail override still applies for a console command other than migrate (e.g. schedule:run/tinker)', function () {
// Reproduces the real production bug: settingsTableUsable() used to
// blanket-skip for *any* console command, which meant scheduled
// commands (emails:fetch-imap, tickets:check-sla-breaches) always sent
// mail via the .env "log" mailer instead of the configured SMTP server,
// since AppServiceProvider::boot() runs on every process including
// console ones. Only the migrate family should still be excluded.
$originalArgv = $_SERVER['argv'] ?? null;
Settings::set('mail_smtp_enabled', '1');
Settings::set('mail_smtp_host', 'smtp.enabled.example');
try {
$_SERVER['argv'] = ['artisan', 'emails:fetch-imap'];
(new AppServiceProvider(app()))->boot();
expect(config('mail.default'))->toBe('smtp');
Config::set('mail.default', 'log');
$_SERVER['argv'] = ['artisan', 'migrate'];
(new AppServiceProvider(app()))->boot();
expect(config('mail.default'))->not->toBe('smtp');
} finally {
$_SERVER['argv'] = $originalArgv;
}
});

View 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]);
});

View File

@@ -45,7 +45,12 @@ test('with obfuscation on, the ticket URL and the displayed number both use the
->and($ticket->displayNumber())->toBe('#'.$ticket->checksum);
$this->actingAs($operator)->get($url)->assertOk();
$this->actingAs($operator)->get('/operator/tickets/1042')->assertNotFound();
// A "no ticket matches this identifier" route-binding failure now
// redirects to the area's own queue instead of a bare 404 (see
// bootstrap/app.php) — the raw sequential number still doesn't resolve
// to the ticket, it just no longer surfaces as a dead-end error page.
$this->actingAs($operator)->get('/operator/tickets/1042')->assertRedirect(route('operator.queue'));
});
test('the API still binds tickets by numeric id regardless of the obfuscation setting', function () {

View 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);
});

View File

@@ -3,7 +3,8 @@
Panel administratora (`/admin`) to jedno miejsce do konfiguracji całego systemu:
struktura zgłoszeń (kategorie, pola, statusy, priorytety, SLA), automatyzacje
(reguły SLA, wyzwalacze), użytkownicy i zespoły, treści (szablony, szybkie
akcje, e-maile), wygląd/branding oraz integracje (LDAP, SMTP, BookStack, API).
akcje, e-maile), wygląd/branding oraz integracje (LDAP, poczta SMTP/IMAP,
BookStack, API).
Domyślnie każde konto ląduje po zalogowaniu w panelu Klienta; przełącz się do
panelu Administratora przez menu profilu (prawy górny róg).
@@ -174,8 +175,58 @@ ważne + treść HTML).
ustawienia.
SMTP (host, port, szyfrowanie, użytkownik/hasło, adres/nazwa nadawcy, z
przyciskiem **„Testuj połączenie”**) konfiguruje się w zakładce **E-MAIL**,
razem z layoutem/stopką wiadomości — patrz sekcja wyżej.
przyciskiem **„Testuj połączenie”**) konfiguruje się w zakładce **Poczta**,
razem z layoutem/stopką wiadomości i skrzynkami IMAP (patrz niżej).
## Poczta — odbieranie zgłoszeń i odpowiedzi e-mailem (IMAP)
Zakładka **Poczta** (dawniej „E-MAIL") łączy konfigurację SMTP (wysyłka) ze
skrzynkami IMAP (odbiór) — obie strony wymiany e-mailowej z klientem żyją
razem, zamiast być rozrzucone po różnych zakładkach.
- **Wiele skrzynek IMAP jednocześnie** — np. `zgloszenia-it@firma.pl` i
`zgloszenia-hr@firma.pl` jako dwie osobne, niezależnie włączane skrzynki,
każda z własnym hostem/portem/szyfrowaniem/loginem/hasłem i folderem.
- **Cel nowych zgłoszeń** — jeden wspólny selektor pozwala wybrać albo
**konkretną podkategorię** (trafi też do jej zespołu, tak jak zgłoszenie
założone przez formularz web), albo **całą kategorię** bez wskazywania
podkategorii (zgłoszenie zostaje nieprzypisane do zespołu, ale kategoria
jest widoczna i można po niej filtrować kolejkę operatora), albo zostawić
puste (zgłoszenie całkiem nieprzypisane).
- **Dopasowywanie odpowiedzi** — odpowiedź na powiadomienie e-mail (temat
zawiera numer/sumę kontrolną zgłoszenia) trafia jako kolejna wiadomość do
tego samego wątku, nie jako nowe zgłoszenie — widoczna na żywo u operatora,
tak jak każda inna odpowiedź.
- **Filtry przed śmieciowymi zgłoszeniami** — automatyczne odpowiedzi
(autorespondery, „poza biurem”, bounce/mailer-daemon) są rozpoznawane po
nagłówkach (`Auto-Submitted`, `Precedence`) i typowych frazach w temacie
(PL i EN) i **odrzucane bez tworzenia zgłoszenia**; dodatkowa lista
zablokowanych nadawców per skrzynka (domyślnie `mailer-daemon, postmaster,
no-reply, noreply`).
- **„Tylko użytkownicy z LDAP”** (Integracje → LDAP) działa identycznie dla
poczty jak dla formularza gościa na stronie głównej — jeśli włączone, e-mail
od nieznanego nadawcy (spoza LDAP i bez lokalnego konta) jest odrzucany, nie
tworzy zgłoszenia.
- **Folder po przetworzeniu / folder odrzuconych** (opcjonalnie) — jeśli
puste, wiadomość zostaje na miejscu tylko oznaczona jako przeczytana.
- **Przycisk „Pobierz teraz”** przy każdej skrzynce — ręczne, natychmiastowe
sprawdzenie poczty bez czekania na harmonogram (co 5 minut), działa też dla
wyłączonej skrzynki; pokazuje od razu liczbę nowych/odpowiedzi/odrzuconych/
błędów.
- **Przycisk „Testuj połączenie”** sprawdza niezapisane wartości formularza,
bez zapisywania.
- **Log** — cała aktywność (połączenia, każda decyzja per wiadomość, błędy)
trafia do osobnego pliku `storage/logs/imap-*.log`, niezależnie od
ogólnego poziomu logowania aplikacji — najlepsze miejsce do sprawdzenia,
dlaczego dany e-mail się nie przetworzył.
- **Znacznik „e-mail"** — zgłoszenie i pojedyncze wiadomości utworzone z
poczty mają widoczną ikonę koperty w kolejce operatora i w widoku
zgłoszenia, odróżniając je od zgłoszeń/odpowiedzi z formularza web.
> Sprawdzanie skrzynek działa cyklicznie tylko wtedy, gdy na serwerze jest
> skonfigurowany zewnętrzny cron wywołujący `php artisan schedule:run` (patrz
> [install.md](../../install.md)) — bez tego działa wyłącznie przycisk
> „Pobierz teraz”.
## Integracje

View File

@@ -49,7 +49,13 @@ swoje.
**Akcje zbiorcze**: zaznacz kilka zgłoszeń checkboxami, by je **scalić** (pierwsze
zaznaczone staje się główne, reszta trafia do niego jako wiadomości i zostaje
zamknięta) albo **usunąć**.
zamknięta) albo **usunąć**. Checkbox w nagłówku tabeli zaznacza/odznacza od
razu wszystkie zgłoszenia aktualnie widoczne pod bieżącym filtrem/zakładką
(nie wszystkie w systemie).
Zgłoszenie założone albo odpowiedziane przez e-mail (patrz konfiguracja w
Admin > Poczta) ma widoczną ikonę koperty obok numeru w kolejce oraz przy
konkretnej wiadomości w wątku zgłoszenia.
Kolejka aktualizuje się **na żywo** — nowe zgłoszenie, zmiana statusu/priorytetu/
przypisania czy nowa odpowiedź pojawiają się bez odświeżania strony. Obok