diff --git a/exec/lib/MailboxDirectory.php b/exec/lib/MailboxDirectory.php index fbcc131..e92b3d7 100644 --- a/exec/lib/MailboxDirectory.php +++ b/exec/lib/MailboxDirectory.php @@ -30,25 +30,64 @@ final class MailboxDirectory return $out; } + /** @return array{owner:string,domain:string,mailbox:string,email:string}|null */ + public function resolveRecipient(string $email): ?array + { + $email = strtolower(trim($email)); + if (!preg_match('/^([A-Za-z0-9._+-]+)@([A-Za-z0-9.-]+)$/', $email, $m)) { + return null; + } + $local = $m[1]; + $domain = $m[2]; + $owners = $this->domainOwners(); + $owner = $owners[$domain] ?? null; + if ($owner === null) { + return null; + } + $passwd = $this->virtualRoot . '/' . $domain . '/passwd'; + if (!is_file($passwd)) { + return null; + } + foreach (file($passwd, FILE_IGNORE_NEW_LINES) ?: [] as $line) { + $mailbox = strtolower(trim(strtok($line, ':') ?: '')); + if ($mailbox === $local) { + return ['owner' => $owner, 'domain' => $domain, 'mailbox' => $local, 'email' => $local . '@' . $domain]; + } + } + return null; + } + /** @return string[] */ private function domainsForUser(string $username): array + { + $domains = []; + foreach ($this->domainOwners() as $domain => $owner) { + if ($owner === $username) { + $domains[] = $domain; + } + } + sort($domains); + return $domains; + } + + /** @return array */ + private function domainOwners(): array { $path = $this->virtualRoot . '/domainowners'; if (!is_file($path)) { return []; } - $domains = []; + $owners = []; foreach (file($path, FILE_IGNORE_NEW_LINES) ?: [] as $line) { if (!str_contains($line, ':')) { continue; } [$domain, $owner] = array_map('trim', explode(':', $line, 2)); - if ($owner !== $username || !preg_match('/^[A-Za-z0-9.-]+$/', $domain)) { + if (!preg_match('/^[A-Za-z0-9.-]+$/', $domain) || !preg_match('/^[A-Za-z0-9._-]+$/', $owner)) { continue; } - $domains[] = strtolower($domain); + $owners[strtolower($domain)] = $owner; } - sort($domains); - return $domains; + return $owners; } } diff --git a/plugin.conf b/plugin.conf index 9fe7d1b..77b4107 100644 --- a/plugin.conf +++ b/plugin.conf @@ -2,6 +2,6 @@ name=global-autoresponder id=global-autoresponder type=user author=HITME.PL -version=1.0.4 +version=1.0.5 active=no installed=no diff --git a/scripts/global_autoresponder_worker.php b/scripts/global_autoresponder_worker.php index 5e2b7a7..278be5c 100755 --- a/scripts/global_autoresponder_worker.php +++ b/scripts/global_autoresponder_worker.php @@ -6,5 +6,145 @@ require_once dirname(__DIR__) . '/exec/lib/Settings.php'; require_once dirname(__DIR__) . '/exec/lib/MessageClassifier.php'; require_once dirname(__DIR__) . '/exec/lib/RepeatState.php'; require_once dirname(__DIR__) . '/exec/lib/MailSender.php'; +require_once dirname(__DIR__) . '/exec/lib/MailboxDirectory.php'; +require_once dirname(__DIR__) . '/exec/lib/RuleRepository.php'; -echo "global-autoresponder worker placeholder: enable only after safe Exim integration review.\n"; +final class GlobalAutoresponderWorker +{ + /** @param callable(string,string,string,string):bool $send */ + public function __construct( + private Settings $settings, + private RuleRepository $rules, + private MailboxDirectory $mailboxes, + private RepeatState $repeat, + private $send, + private ?int $now = null + ) { + } + + /** @param array $env */ + public function handle(array $env, string $message): int + { + if ($this->settings->backendMode() !== 'exim') { + return 0; + } + $sender = self::firstEnv($env, ['SENDER_ADDRESS', 'sender_address', 'SENDER', 'sender']); + $recipient = self::recipientFromEnv($env); + if ($sender === '' || $recipient === '' || !self::isEmail($sender) || !self::isEmail($recipient)) { + return 0; + } + $headers = self::parseHeaders($message); + if (MessageClassifier::shouldSuppress($headers, $sender)) { + return 0; + } + $resolved = $this->mailboxes->resolveRecipient($recipient); + if ($resolved === null) { + return 0; + } + $sent = 0; + $owner = $resolved['owner']; + $recipient = $resolved['email']; + foreach ($this->rules->all($owner) as $rule) { + if (!$this->isActiveRule($rule)) { + continue; + } + $ruleId = (string)($rule['id'] ?? ''); + if ($ruleId === '') { + continue; + } + $repeatMinutes = $this->settings->replyEveryMessage() ? 0 : $this->settings->repeatMinutes(); + if (!$this->repeat->shouldSend($owner, $ruleId, $recipient, $sender, $repeatMinutes)) { + continue; + } + $ok = (bool)call_user_func( + $this->send, + $recipient, + strtolower($sender), + (string)$rule['subject'], + (string)$rule['body'] + ); + if ($ok) { + $this->repeat->record($owner, $ruleId, $recipient, $sender); + $sent++; + } + } + return $sent; + } + + /** @param array $rule */ + private function isActiveRule(array $rule): bool + { + if (empty($rule['enabled'])) { + return false; + } + $now = $this->now ?? time(); + return (int)($rule['start_ts'] ?? 0) <= $now && (int)($rule['end_ts'] ?? 0) >= $now; + } + + /** @param array $env @param string[] $keys */ + private static function firstEnv(array $env, array $keys): string + { + foreach ($keys as $key) { + $value = trim((string)($env[$key] ?? '')); + if ($value !== '') { + return strtolower($value); + } + } + return ''; + } + + /** @param array $env */ + private static function recipientFromEnv(array $env): string + { + $direct = self::firstEnv($env, ['LOCAL_PART_DOMAIN', 'RECIPIENT', 'ORIGINAL_RECIPIENT', 'local_part_domain']); + if ($direct !== '') { + return $direct; + } + $local = self::firstEnv($env, ['LOCAL_PART', 'local_part']); + $domain = self::firstEnv($env, ['DOMAIN', 'domain']); + return ($local !== '' && $domain !== '') ? $local . '@' . $domain : ''; + } + + private static function isEmail(string $value): bool + { + return (bool)preg_match('/^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+$/', $value); + } + + /** @return array */ + public static function parseHeaders(string $message): array + { + $headers = []; + $current = ''; + foreach (preg_split('/\r\n|\n|\r/', $message) ?: [] as $line) { + if ($line === '') { + break; + } + if ($current !== '' && preg_match('/^[ \t]/', $line)) { + $headers[$current] .= ' ' . trim($line); + continue; + } + if (!str_contains($line, ':')) { + continue; + } + [$key, $value] = explode(':', $line, 2); + $current = trim($key); + if ($current !== '') { + $headers[$current] = trim($value); + } + } + return $headers; + } +} + +if (PHP_SAPI === 'cli' && realpath((string)($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) { + $settings = Settings::load(Settings::EXTERNAL_SETTINGS); + $sender = new MailSender(); + $worker = new GlobalAutoresponderWorker( + $settings, + new RuleRepository(), + new MailboxDirectory(), + new RepeatState(), + static fn (string $from, string $to, string $subject, string $body): bool => $sender->send('/usr/sbin/sendmail', $from, $to, $subject, $body) + ); + $worker->handle(getenv(), stream_get_contents(STDIN) ?: ''); +} diff --git a/tests/exim_worker_test.php b/tests/exim_worker_test.php new file mode 100644 index 0000000..520921d --- /dev/null +++ b/tests/exim_worker_test.php @@ -0,0 +1,98 @@ +create('alice', [ + 'subject' => 'Urlop', + 'body' => 'Odpowiem później', + 'start_ts' => 900, + 'end_ts' => 1100, + 'enabled' => true, + ]); + + $sent = []; + $worker = new GlobalAutoresponderWorker( + $settings, + $repo, + new MailboxDirectory($virtualRoot), + new RepeatState(TEST_TMP . '/worker-state', 1000), + static function (string $from, string $to, string $subject, string $body) use (&$sent): bool { + $sent[] = compact('from', 'to', 'subject', 'body'); + return true; + }, + 1000 + ); + + $count = $worker->handle( + ['SENDER_ADDRESS' => 'person@example.net', 'LOCAL_PART' => 'info', 'DOMAIN' => 'example.com'], + "Auto-Submitted: no\nSubject: hello\n\nBody" + ); + + assert_same(1, $count); + assert_same('info@example.com', $sent[0]['from']); + assert_same('person@example.net', $sent[0]['to']); + assert_same('Urlop', $sent[0]['subject']); + assert_same('Odpowiem później', $sent[0]['body']); + + $count = $worker->handle( + ['SENDER_ADDRESS' => 'person@example.net', 'RECIPIENT' => 'info@example.com'], + "Auto-Submitted: no\nSubject: hello\n\nBody" + ); + assert_same(0, $count, 'repeat policy should block immediate second response'); + + assert_true(isset($created['id'])); +}); + +test('exim worker suppresses automated messages and ignores unresolved recipients', function (): void { + $settingsPath = TEST_TMP . '/worker-suppress-settings.conf'; + test_write($settingsPath, "GLOBAL_AUTORESPONDER_BACKEND=exim\nGLOBAL_AUTORESPONDER_REPLY_EVERY_MESSAGE=true\n"); + $settings = Settings::load($settingsPath); + + $virtualRoot = TEST_TMP . '/worker-suppress-virtual'; + test_write($virtualRoot . '/domainowners', "example.com: alice\n"); + test_write($virtualRoot . '/example.com/passwd', "info:x:1000:12::/home/alice/imap/example.com/info:/bin/false\n"); + test_write($virtualRoot . '/example.com/aliases', "alias: info\n"); + + $repo = new RuleRepository(TEST_TMP . '/worker-suppress-data'); + $repo->create('alice', [ + 'subject' => 'Urlop', + 'body' => 'Body', + 'start_ts' => 1, + 'end_ts' => 2000, + 'enabled' => true, + ]); + + $sent = 0; + $worker = new GlobalAutoresponderWorker( + $settings, + $repo, + new MailboxDirectory($virtualRoot), + new RepeatState(TEST_TMP . '/worker-suppress-state', 1000), + static function () use (&$sent): bool { + $sent++; + return true; + }, + 1000 + ); + + assert_same(0, $worker->handle( + ['SENDER_ADDRESS' => 'person@example.net', 'RECIPIENT' => 'info@example.com'], + "Precedence: bulk\n\nBody" + )); + assert_same(0, $worker->handle( + ['SENDER_ADDRESS' => 'person@example.net', 'RECIPIENT' => 'alias@example.com'], + "Auto-Submitted: no\n\nBody" + )); + assert_same(0, $sent); +}); diff --git a/tests/mailbox_directory_test.php b/tests/mailbox_directory_test.php index a7e9983..4097726 100644 --- a/tests/mailbox_directory_test.php +++ b/tests/mailbox_directory_test.php @@ -14,4 +14,7 @@ test('mailbox directory returns only pop imap mailboxes owned by user', function $mailboxes = $dir->mailboxesForUser('alice'); assert_same(['info@example.com', 'sales@example.com'], array_column($mailboxes, 'email')); + assert_same('alice', $dir->resolveRecipient('info@example.com')['owner'] ?? ''); + assert_same(null, $dir->resolveRecipient('alias@example.com')); + assert_same(null, $dir->resolveRecipient('missing@example.com')); });