di = $di; } public function getDi(): ?Container { return $this->di; } public function getModulePermissions(): array { return ['manage_settings' => [ 'type' => 'bool', 'display_name' => 'Manage Mail Gateway settings', 'description' => 'Configure the inbound mailbox and ticket routing.', ]]; } public function getSettings(): array { return $this->settingsStore()->getPublic(); } public function getMailboxes(): array { return $this->settingsStore()->getAllPublic(); } public function getDependencyStatus(): array { return ($this->di['mailgateway_dependency_installer'] ?? new DependencyInstaller())->status(); } public function installDependencies(bool $force = false): array { // Share the poller's lock so imports cannot run while vendor is being written. if ((int) $this->di['dbal']->fetchOne('SELECT GET_LOCK(?, 0)', ['fossbilling_mailgateway_poll']) !== 1) { throw new InformationException('Mail Gateway is busy. Try installing dependencies again after polling finishes.'); } try { return ($this->di['mailgateway_dependency_installer'] ?? new DependencyInstaller())->install($force); } finally { $this->di['dbal']->fetchOne('SELECT RELEASE_LOCK(?)', ['fossbilling_mailgateway_poll']); } } public function testConnection(array $data): array { $id = $data['mailbox_id'] ?? 'default'; if (!is_string($id)) { throw new \FOSSBilling\InformationException('Mailbox not found'); } $store = $this->settingsStore(); $settings = $store->get($id); $result = ($this->di['mailgateway_connection_test'] ?? new ImapConnectionTest())->test($settings, true); $store->recordTest($id, $settings, $result); return $result; } public function saveSettings(array $data): bool { if (in_array($data['enabled'] ?? false, [true, 1, '1'], true)) { $status = $this->configureNotifications(); if (!$status['ready']) { throw new InformationException(implode(' ', $status['errors'])); } } return $this->settingsStore()->save($data); } public function getNotificationStatus(): array { return (new NotificationSettings($this->di))->status(); } public function configureNotifications(): array { return (new NotificationSettings($this->di))->repair(); } public function deleteMailbox(array $data): bool { $id = $data['mailbox_id'] ?? null; if (!is_string($id) || $id === '') { throw new InformationException('Mailbox ID is required'); } // Prevent a removed mailbox from being imported by an already-running poll. if ((int) $this->di['dbal']->fetchOne('SELECT GET_LOCK(?, 0)', ['fossbilling_mailgateway_poll']) !== 1) { throw new InformationException('Mail Gateway is busy. Try deleting the mailbox after polling finishes.'); } try { return $this->settingsStore()->delete($id); } finally { $this->di['dbal']->fetchOne('SELECT RELEASE_LOCK(?)', ['fossbilling_mailgateway_poll']); } } private function settingsStore(): object { return $this->di['mailgateway_settings'] ?? new Settings($this->di); } public function poll(bool $force = false): array { return (new Poller($this->di, $this))->poll($force); } public static function onBeforeAdminCronRun(\Box_Event $event): void { $di = $event->getDi(); try { $result = $di['mod_service']('mailgateway')->poll(); if ($result['status'] === 'completed') { $di['logger']->info('Mail Gateway polling: ' . json_encode($result, JSON_THROW_ON_ERROR)); } } catch (\Throwable) { // A mail gateway outage must not interrupt billing or the outgoing queue. $di['logger']->warning('Mail Gateway polling unavailable; FOSSBilling cron will continue'); } } private function setupTables(): void { foreach (explode(';', file_get_contents(__DIR__ . '/schema.sql')) as $statement) { if (trim($statement) !== '') { $this->di['dbal']->executeStatement($statement); } } } private function setupTemplates(): void { ($this->di['mailgateway_template_installer'] ?? new TemplateInstaller($this->di))->install(); } public function setup(): bool { if ((int) $this->di['dbal']->fetchOne('SELECT GET_LOCK(?, 0)', ['fossbilling_mailgateway_poll']) !== 1) { throw new InformationException('Mail Gateway is busy. Try setup again after polling finishes.'); } try { $this->setupTables(); $this->setupTemplates(); return true; } finally { $this->di['dbal']->fetchOne('SELECT RELEASE_LOCK(?)', ['fossbilling_mailgateway_poll']); } } public function install(): bool { $this->setupTables(); $this->setupTemplates(); try { $this->installDependencies(); } catch (\Throwable) { // Keep the settings page reachable if the host cannot install Composer packages. // Dependency status there provides the install button or SSH instructions. $this->di['logger']->warning('Mail Gateway tables are ready, but dependencies could not be installed automatically. Open Mail Gateway settings to finish installation.'); } return true; } /** Create a customer or guest ticket from a decoded inbound message. */ public function import(array $data): array { $policy = new InboundPolicy(); $reason = $policy->ignoreReason($policy->headers($data)); if ($reason !== null) { return ['action' => 'ignored', 'reason' => $reason]; } $sender = $this->senderEmail($data); $subject = $this->requiredString($data, 'subject'); if (!mb_check_encoding($subject, 'UTF-8') || mb_strlen($subject, 'UTF-8') > 255 || preg_match('/[\x00-\x1F\x7F]/', $subject)) { throw new InformationException('Subject must be valid UTF-8, at most 255 characters, with no control characters'); } $content = $this->messageContent($data); $senderName = isset($data['sender_name']) ? $this->requiredString($data, 'sender_name') : $sender; if (!mb_check_encoding($senderName, 'UTF-8') || mb_strlen($senderName, 'UTF-8') > 255 || preg_match('/[\x00-\x1F\x7F]/', $senderName)) { throw new InformationException('Sender name must be valid UTF-8, at most 255 characters, with no control characters'); } // Exact mailbox matching, case-insensitive; never use LIKE or choose the first match. $clients = $this->di['dbal']->fetchFirstColumn( 'SELECT id FROM client WHERE LOWER(email) = LOWER(:email) LIMIT 2', ['email' => $sender], ); if (count($clients) > 1) { throw new InformationException('Multiple customers match sender_email'); } $support = $this->di['mod_service']('support'); $store = $this->settingsStore(); $settings = isset($data['mailbox']) && $store instanceof Settings ? $store->forAddress($data['mailbox']) : $store->get(); $helpdeskId = $settings['support_helpdesk_id']; $helpdesks = $helpdeskId !== null ? [$support->getHelpdeskRepository()->find((int) $helpdeskId)] : $support->getHelpdeskRepository()->findBy(['name' => 'General'], null, 2); if (count($helpdesks) !== 1 || !$helpdesks[0] instanceof Helpdesk) { throw new InformationException('Choose an existing helpdesk in Mail Gateway settings, or configure exactly one General helpdesk'); } if ($clients === []) { // Guest creation sends its own acknowledgement without a client // session. Keep its access hash private; return only the ticket ID. $hash = $this->withNotificationCooldown($sender, fn () => $support->ticketCreateForGuest([ 'name' => $senderName, 'email' => $sender, 'subject' => $subject, 'content' => $content, 'support_helpdesk_id' => $helpdesks[0]->getId(), ])); $ticket = $support->findOneByHash($hash); return ['ticket_id' => $ticket->getId(), 'client_id' => null, 'support_helpdesk_id' => $helpdesks[0]->getId()]; } $client = $this->di['db']->getExistingModelById('Client', (int) $clients[0]); if (!$client instanceof \Model_Client) { throw new InformationException('Customer identity could not be loaded'); } $id = $this->withNotificationCooldown($sender, fn () => $support->ticketCreateForClient($client, $helpdesks[0], [ 'subject' => $subject, 'content' => $content, ])); // A canned acknowledgement counts as an admin reply in Support and // puts the ticket on hold. Incoming mail still needs staff attention. $ticket = $support->getTicketById($id); if ($ticket->getStatus() === SupportTicket::STATUS_ONHOLD) { $ticket->setStatus(SupportTicket::STATUS_OPEN); $this->di['em']->flush(); } // Support's normal notification hook requires a customer session. // Cron imports have no customer session. if (!$this->di['auth']->isClientLoggedIn()) { $this->queueAutomaticTemplate($sender, [ 'to_client' => (int) $client->id, 'code' => 'mod_support_ticket_open', 'ticket' => $support->toApiArray($ticket, true, $client), ]); } return ['ticket_id' => $id, 'client_id' => (int) $client->id, 'support_helpdesk_id' => $helpdesks[0]->getId()]; } /** Reply to an explicitly identified ticket; never infer ownership from a subject. */ public function reply(array $data): array { $policy = new InboundPolicy(); $reason = $policy->ignoreReason($policy->headers($data)); if ($reason !== null) { return ['action' => 'ignored', 'reason' => $reason]; } $sender = $this->senderEmail($data); $content = $this->messageContent($data); $rawId = $data['ticket_id'] ?? null; if ((!is_int($rawId) && !is_string($rawId)) || filter_var($rawId, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) === false) { throw new InformationException('A positive integer ticket_id is required'); } $support = $this->di['mod_service']('support'); $ticket = $support->getTicketById((int) $rawId); if ($ticket->isGuestTicket()) { if (strcasecmp($sender, (string) $ticket->getAuthorEmail()) !== 0) { throw new InformationException('Sender does not own this ticket'); } $identity = new \FOSSBilling\Identity\Guest(); } else { $ids = $this->di['dbal']->fetchFirstColumn( 'SELECT id FROM client WHERE LOWER(email) = LOWER(:email) LIMIT 2', ['email' => $sender], ); if (count($ids) !== 1 || (int) $ids[0] !== $ticket->getClientId()) { throw new InformationException('Sender does not uniquely match the ticket owner'); } $identity = $this->di['db']->getExistingModelById('Client', (int) $ids[0]); if (!$identity instanceof \Model_Client) { throw new InformationException('Customer identity could not be loaded'); } } if (!$support->canBeReopened($ticket)) { throw new InformationException('Ticket cannot be reopened'); } $messageId = $support->ticketReply($ticket, $identity, $content); return ['ticket_id' => $ticket->getId(), 'message_id' => $messageId]; } public function notifySenderMismatch(string $sender, int $ticketId): void { try { $this->queueAutomaticTemplate($sender, [ 'to' => $sender, 'code' => 'mailgateway_sender_mismatch', 'sender_email' => $sender, // Only echo the reference supplied by the sender; do not pass // the ticket's details, owner identity, or guest access hash. 'ticket_id' => $ticketId, ]); } catch (\Exception) { // Notification failure must never permit the rejected reply. $this->di['logger']->warning('Mail Gateway could not queue the sender mismatch notification'); } } private function senderEmail(array $data): string { $sender = $this->requiredString($data, 'sender_email'); if (strlen($sender) > 255 || !filter_var($sender, FILTER_VALIDATE_EMAIL)) { throw new InformationException('A valid sender_email is required'); } return $sender; } private function queueAutomaticTemplate(string $sender, array $data): bool { // An injectable limiter permits isolated tests without a live database. $limiter = $this->di['mailgateway_notification_cooldown'] ?? new NotificationCooldown($this->di['dbal']); return $limiter->run($sender, fn () => $this->di['mod_service']('email')->sendTemplate($data)); } private function withNotificationCooldown(string $sender, callable $create): mixed { $lookup = $this->di['mod_service']; $proxy = null; unset($this->di['mod_service']); $this->di['mod_service'] = $this->di->protect(function ($module, $sub = '') use ($lookup, $sender, &$proxy) { if (strtolower($module) !== 'email' || $sub !== '') { return $lookup($module, $sub); } if ($proxy === null) { $email = $lookup($module, $sub); $proxy = new NotificationEmailProxy($email, function ($data) use ($sender, $email) { $limiter = $this->di['mailgateway_notification_cooldown'] ?? new NotificationCooldown($this->di['dbal']); return $limiter->run($sender, fn () => $email->sendTemplate($data)); }); } return $proxy; }); try { return $create(); } finally { unset($this->di['mod_service']); $this->di['mod_service'] = $this->di->protect($lookup); } } private function messageContent(array $data): string { $content = $this->requiredString($data, 'content'); // Support stores this in a TEXT column (maximum 65,535 bytes). if (!mb_check_encoding($content, 'UTF-8') || strlen($content) > RuntimeConfig::get('max_ticket_body_bytes')) { throw new InformationException('Content must be valid UTF-8 and within the configured ticket body limit'); } $content = Tools::sanitizeMarkdownContent($content); if ($content === '') { throw new InformationException('Content must not be empty'); } return $content; } private function requiredString(array $data, string $key): string { if (!isset($data[$key]) || !is_string($data[$key]) || trim($data[$key]) === '') { throw new InformationException($key . ' must be a non-empty string'); } return trim($data[$key]); } }