v1.2.0
- E-mail intake (IMAP), optional and off by default: clients can create a ticket or reply to an existing one just by sending/replying to an e-mail. Configure any number of mailboxes in the new Admin > Poczta page (SMTP + IMAP together, replacing the old "E-MAIL" tab), each routed to a specific subcategory or a whole category (new tickets.category_id column). Replies are matched to their ticket via the number/checksum already in every notification subject; autoresponders/bounces are detected and rejected; "restrict tickets to LDAP" is enforced for e-mail like the guest web form. Manual "Pobierz teraz" per-mailbox fetch button; dedicated storage/logs/imap-*.log regardless of the app's log level; mail-icon badges on e-mail-originated tickets/messages in the operator queue and ticket view. - Operator queue: "select all" checkbox in the table header for every currently visible ticket under the active filter/tab. - Fixed: scheduled commands (SLA breach check, automation rules, and now IMAP fetch) always sent notifications through .env's default mailer instead of the configured SMTP server, because AppServiceProvider's Settings override used to skip itself for any console command, not just migrate. - Fixed: visiting a ticket that no longer exists (deleted mid-session, or a stale background refresh) showed a raw 404 instead of redirecting back to the operator queue / client dashboard. - Docs: README/ARCHITECTURE/CLAUDE/install/wiki updated for all of the above, including the previously-missing host crontab entry for schedule:run. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
107
ARCHITECTURE.md
107
ARCHITECTURE.md
@@ -101,6 +101,18 @@ The `{numer}` placeholder available in admin-editable e-mail templates
|
||||
own `#{numer}`, so adding the prefix there too would double it up or clash
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user