- 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>
216 lines
7.4 KiB
PHP
216 lines
7.4 KiB
PHP
<?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();
|
|
});
|