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