- 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>
138 lines
4.9 KiB
PHP
138 lines
4.9 KiB
PHP
<?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;
|
|
}
|
|
}
|