* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderImap\Stores; use DI\Attribute\Inject; use FilesystemIterator; use InvalidArgumentException; use JsonException; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use RuntimeException; /** * IMAP Message Content Store * * Holds the immutable content of cached messages (message.json) on the filesystem: * * {rootDir}/storage/{tid}/provider_imap/{sid}/{sha1(mailbox)}/{uidValidity}/{uid}/message.json * * Mailbox names are hashed because they contain delimiters and modified UTF-7. * Files are written atomically (temporary file + rename). */ class MessageFileStore { private const CONTENT_FILE = 'message.json'; private const FOLDER_PERMISSIONS = 0750; private const FILE_PERMISSIONS = 0640; public function __construct( #[Inject('rootDir')] private readonly string $rootDir, ) {} /** * Write the content of a message, replacing any existing content. */ public function write(string $tenantId, string $serviceId, string $mailbox, int $uidValidity, int $uid, array $content): void { $folder = $this->messagePath($tenantId, $serviceId, $mailbox, $uidValidity, $uid); if (!is_dir($folder) && !@mkdir($folder, self::FOLDER_PERMISSIONS, true) && !is_dir($folder)) { throw new RuntimeException("Failed to create message folder: {$folder}"); } try { $json = json_encode($content, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); } catch (JsonException $e) { throw new RuntimeException('Failed to encode message content: ' . $e->getMessage(), 0, $e); } $file = $folder . DIRECTORY_SEPARATOR . self::CONTENT_FILE; $temporary = $file . '.' . bin2hex(random_bytes(6)) . '.tmp'; if (@file_put_contents($temporary, $json, LOCK_EX) !== strlen($json)) { @unlink($temporary); throw new RuntimeException("Failed to write message content: {$file}"); } @chmod($temporary, self::FILE_PERMISSIONS); if (!@rename($temporary, $file)) { @unlink($temporary); throw new RuntimeException("Failed to replace message content: {$file}"); } } /** * Read the content of a message. * * @return array|null null when the message is not cached or its content is unreadable */ public function read(string $tenantId, string $serviceId, string $mailbox, int $uidValidity, int $uid): ?array { $file = $this->messagePath($tenantId, $serviceId, $mailbox, $uidValidity, $uid) . DIRECTORY_SEPARATOR . self::CONTENT_FILE; if (!is_file($file)) { return null; } $json = @file_get_contents($file); if ($json === false) { return null; } try { $content = json_decode($json, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException) { return null; } return is_array($content) ? $content : null; } /** * Delete the cached files of messages. */ public function delete(string $tenantId, string $serviceId, string $mailbox, int $uidValidity, int ...$uids): void { foreach ($uids as $uid) { $this->removeTree($this->messagePath($tenantId, $serviceId, $mailbox, $uidValidity, $uid)); } } /** * Delete the cached files of a mailbox (all UIDVALIDITY generations). */ public function deleteByMailbox(string $tenantId, string $serviceId, string $mailbox): void { $this->removeTree($this->mailboxPath($tenantId, $serviceId, $mailbox)); } /** * Delete the cached files of a service. */ public function deleteByService(string $tenantId, string $serviceId): void { $this->removeTree($this->servicePath($tenantId, $serviceId)); } private function servicePath(string $tenantId, string $serviceId): string { return implode(DIRECTORY_SEPARATOR, [ rtrim($this->rootDir, DIRECTORY_SEPARATOR), 'storage', self::pathSegment($tenantId, 'tenant'), 'provider_imap', self::pathSegment($serviceId, 'service'), ]); } private function mailboxPath(string $tenantId, string $serviceId, string $mailbox): string { return $this->servicePath($tenantId, $serviceId) . DIRECTORY_SEPARATOR . sha1($mailbox); } private function messagePath(string $tenantId, string $serviceId, string $mailbox, int $uidValidity, int $uid): string { if ($uidValidity < 0 || $uid <= 0) { throw new InvalidArgumentException('Invalid UIDVALIDITY or UID'); } return $this->mailboxPath($tenantId, $serviceId, $mailbox) . DIRECTORY_SEPARATOR . $uidValidity . DIRECTORY_SEPARATOR . $uid; } /** * Guard identifiers used as path segments against traversal. */ private static function pathSegment(string $value, string $label): string { if (preg_match('/^[A-Za-z0-9_-]+$/', $value) !== 1) { throw new InvalidArgumentException("Invalid {$label} identifier for cache path"); } return $value; } private function removeTree(string $path): void { if (!file_exists($path)) { return; } if (!is_dir($path) || is_link($path)) { @unlink($path); return; } $items = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST, ); foreach ($items as $item) { $item->isDir() && !$item->isLink() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); } @rmdir($path); } }