storagePath = $this->rootDir . '/var/cache/mail/composer'; } public function compositionFetch(string $tenantId, string $userId, string $draftId): ?array { return $this->withCompositionLock( $tenantId, $userId, $draftId, LOCK_SH, fn(): ?array => $this->compositionRead($tenantId, $userId, $draftId), ); } /** * Atomically read, mutate, and replace a composition snapshot. * * Returning null from the callback leaves the stored snapshot unchanged. * The callback receives null when the composition does not exist. * * @param callable(?array): ?array $update */ public function compositionSave(string $tenantId, string $userId, string $draftId, callable $update): ?array { return $this->withCompositionLock( $tenantId, $userId, $draftId, LOCK_EX, function () use ($tenantId, $userId, $draftId, $update): ?array { $snapshot = $update($this->compositionRead($tenantId, $userId, $draftId)); if ($snapshot === null) { return null; } if (!is_array($snapshot)) { throw new UnexpectedValueException('Composition save callback must return an array or null'); } $json = json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); $draftDir = $this->draftDir($tenantId, $userId, $draftId); $this->ensureDirectory($draftDir); $temporaryPath = tempnam($draftDir, '.composition-'); if ($temporaryPath === false) { throw new RuntimeException("Unable to create a temporary composition file for '$draftId'"); } try { $written = file_put_contents($temporaryPath, $json); if ($written !== strlen($json)) { throw new RuntimeException("Unable to write composition '$draftId'"); } if (!rename($temporaryPath, $draftDir . '/' . self::COMPOSITION_FILENAME)) { throw new RuntimeException("Unable to replace composition '$draftId'"); } } finally { if (is_file($temporaryPath)) { unlink($temporaryPath); } } return $snapshot; }, ); } public function compositionDiscard(string $tenantId, string $userId, string $draftId): bool { return $this->withCompositionLock( $tenantId, $userId, $draftId, LOCK_EX, function () use ($tenantId, $userId, $draftId): bool { $draftDir = $this->draftDir($tenantId, $userId, $draftId); if (!is_dir($draftDir)) { return false; } $this->deleteDir($draftDir); return true; }, ); } private function compositionRead(string $tenantId, string $userId, string $draftId): ?array { $messagePath = $this->draftDir($tenantId, $userId, $draftId) . '/' . self::COMPOSITION_FILENAME; if (!is_file($messagePath)) { return null; } $contents = file_get_contents($messagePath); if ($contents === false) { throw new RuntimeException("Unable to read composition '$draftId'"); } $decoded = json_decode($contents, true); return is_array($decoded) ? $decoded : null; } private function withCompositionLock(string $tenantId, string $userId, string $draftId, int $operation, callable $callback): mixed { $lockDir = $this->storagePath . '/.locks'; $this->ensureDirectory($lockDir); $lockId = hash('sha256', $tenantId . "\0" . $userId . "\0" . $draftId); $handle = fopen($lockDir . '/' . $lockId . '.lock', 'c+b'); if ($handle === false) { throw new RuntimeException("Unable to open composition lock for '$draftId'"); } try { if (!flock($handle, $operation)) { throw new RuntimeException("Unable to lock composition '$draftId'"); } return $callback(); } finally { flock($handle, LOCK_UN); fclose($handle); } } public function attachmentStageFromStream(string $tenantId, string $userId, string $compositionId, string $attachmentId, BinaryResource $data): array { $attachmentDir = $this->attachmentDir($tenantId, $userId, $compositionId); if (!is_dir($attachmentDir)) { mkdir($attachmentDir, 0755, true); } $contentPath = $attachmentDir . '/' . $attachmentId . '.blob'; $handle = fopen($contentPath, 'wb'); $size = 0; foreach ($data->stream() as $chunk) { $chunkString = (string)$chunk; $size += strlen($chunkString); fwrite($handle, $chunkString); } fclose($handle); $meta = [ 'identifier' => $attachmentId, 'composition' => $compositionId, 'name' => $data->filename(), 'type' => $data->mimeType(), 'size' => $size, ]; file_put_contents($attachmentDir . '/' . $attachmentId . '.meta', json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); return $meta; } public function attachmentFetchData(string $tenantId, string $userId, string $compositionId, string $attachmentId): ?string { $path = $this->attachmentDir($tenantId, $userId, $compositionId) . '/' . $attachmentId . '.blob'; if (!file_exists($path)) { return null; } return file_get_contents($path) ?: null; } public function fetchAttachmentMeta(string $tenantId, string $userId, string $compositionId, string $attachmentId): ?array { $path = $this->attachmentDir($tenantId, $userId, $compositionId) . '/' . $attachmentId . '.meta'; if (!file_exists($path)) { return null; } $decoded = json_decode((string)file_get_contents($path), true); return is_array($decoded) ? $decoded : null; } private function attachmentDir(string $tenantId, string $userId, string $draftId): string { return $this->draftDir($tenantId, $userId, $draftId) . '/attachments'; } private function draftDir(string $tenantId, string $userId, string $draftId): string { return $this->storagePath . '/' . $tenantId . '/' . $userId . '/' . $draftId; } private function ensureDirectory(string $path): void { if (!is_dir($path) && !mkdir($path, 0755, true) && !is_dir($path)) { throw new RuntimeException("Unable to create directory '$path'"); } } private function deleteDir(string $path): void { if (!is_dir($path)) { return; } foreach (scandir($path) ?: [] as $entry) { if ($entry === '.' || $entry === '..') { continue; } $child = $path . '/' . $entry; if (is_dir($child)) { $this->deleteDir($child); continue; } unlink($child); } rmdir($path); } }