feat: implement exim autoresponder worker

This commit is contained in:
Marek Miklewicz
2026-06-02 20:17:49 +02:00
parent 7493e3f9da
commit d3f2fd69db
5 changed files with 287 additions and 7 deletions
+44 -5
View File
@@ -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<string,string> */
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;
}
}
+1 -1
View File
@@ -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
+141 -1
View File
@@ -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<string,string> $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<string,mixed> $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<string,string> $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<string,string> $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<string,string> */
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) ?: '');
}
+98
View File
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
require_once PLUGIN_ROOT . '/scripts/global_autoresponder_worker.php';
test('exim worker sends active rule for resolved mailbox only', function (): void {
$settingsPath = TEST_TMP . '/worker-settings.conf';
test_write($settingsPath, "GLOBAL_AUTORESPONDER_BACKEND=exim\nGLOBAL_AUTORESPONDER_REPEAT_MINUTES=60\n");
$settings = Settings::load($settingsPath);
$virtualRoot = TEST_TMP . '/worker-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");
$repo = new RuleRepository(TEST_TMP . '/worker-data');
$created = $repo->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);
});
+3
View File
@@ -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'));
});