Files
fossbilling-mailgateway/Service.php
T

403 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace Box\Mod\Mailgateway;
use Box\Mod\Support\Entity\Helpdesk;
use Box\Mod\Support\Entity\SupportTicket;
use FOSSBilling\InformationException;
use FOSSBilling\Tools;
use Pimple\Container;
class Service implements \FOSSBilling\InjectionAwareInterface
{
protected ?Container $di = null;
public function setDi(Container $di): void
{
$this->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]);
}
}