403 lines
16 KiB
PHP
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]);
|
|
}
|
|
}
|