- 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:
2026-07-23 12:44:30 +02:00
parent 63178b366e
commit 0d116dfd98
38 changed files with 2290 additions and 164 deletions

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