feat: add attach from documents

Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
2026-08-15 12:55:59 -04:00
parent 8a18494883
commit 4839f822fa
9 changed files with 591 additions and 107 deletions
+207 -45
View File
@@ -5,21 +5,29 @@ declare(strict_types=1);
namespace KTXM\Mail;
use InvalidArgumentException;
use KTXC\Module\ModuleManager;
use KTXF\Mail\Object\Address;
use KTXF\Mail\Object\AddressInterface;
use KTXF\Mail\Object\MessagePart;
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
use KTXF\Mail\Service\ServiceEntitySubmitInterface;
use KTXF\Mail\Submission\EntitySubmitResult;
use KTXF\Resource\Identifier\EntityIdentifierInterface;
use KTXF\Resource\BinaryResource;
use KTXF\Resource\Identifier\EntityIdentifier;
use KTXF\Resource\Identifier\ResourceIdentifier;
use KTXF\Utile\UUID;
use KTXM\Mail\Stores\CompositionStore;
use KTXM\MailManager\Manager as MailManager;
use KTXM\DocumentsManager\Manager as DocumentsManager;
use Psr\Container\ContainerInterface;
class CompositionManager {
private const DOCUMENTS_MODULE_HANDLE = 'documents_manager';
public function __construct(
private readonly ContainerInterface $container,
private readonly ModuleManager $moduleManager,
private readonly CompositionStore $compositionStore,
private readonly MailManager $mailManager,
) {}
@@ -75,7 +83,7 @@ class CompositionManager {
data: $data,
);
// supplement the attachment metadata with source information
$meta['origin'] = 'source';
$meta['origin'] = 'message';
$meta['source'] = $source;
$meta['partId'] = $attachment->getId();
$meta['blobId'] = $attachment->getBlobId();
@@ -292,40 +300,13 @@ class CompositionManager {
return $properties;
}
/**
* @param array<int,array|string> $entries
* @return array<int,Address>
*/
private function mapAddresses(array $entries): array {
$addresses = [];
foreach ($entries as $entry) {
if (is_array($entry)) {
$address = Address::fromArray($entry);
if ($address->getAddress() !== '') {
$addresses[] = $address;
}
continue;
}
if (is_string($entry) && $entry !== '') {
$address = Address::fromString($entry);
if ($address->getAddress() !== '') {
$addresses[] = $address;
}
}
}
return $addresses;
}
public function attachmentAdd(string $tenantId, string $userId, string $composition, array ...$attachments): array {
$result = [
'composition' => $composition,
'attachments' => [],
];
// fetch the composition to ensure it exists
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $composition);
if ($composed === null) {
$result['disposition'] = 'error';
@@ -335,32 +316,169 @@ class CompositionManager {
];
return $result;
}
// separate attachments by type
$uploads = [];
$documents = [];
foreach ($attachments as $attachment) {
if (!isset($attachment['identifier']) || !is_string($attachment['identifier']) || $attachment['identifier'] === '') {
$identifier = UUID::v4();
} else {
$identifier = $attachment['identifier'];
if (isset($attachment['origin']) && $attachment['origin'] === 'documents') {
$documents[] = $attachment;
} elseif (isset($attachment['origin']) && $attachment['origin'] === 'device') {
$uploads[] = $attachment;
}
}
// process uploaded attachments
if ($uploads !== []) {
$this->attachmentAddFromDevice($tenantId, $userId, $composition, $uploads, $composed);
}
// process attachments from the documents module
if ($documents !== []) {
$failed = $documents !== []
? $this->attachmentAddFromDocuments($tenantId, $userId, $composition, $documents, $composed)
: [];
}
// save the updated composition with new attachments
$this->compositionStore->compositionSave($tenantId, $userId, $composition, $composed);
// construct the result
$result['attachments'] = $composed['attachments'];
$result['failed'] = $failed;
$result['disposition'] = match (true) {
$failed === [] => 'added',
$result['attachments'] !== [] => 'partial',
default => 'error',
};
if ($result['disposition'] === 'error') {
$result['error'] = [
'type' => 'attachment_add_failed',
'message' => 'None of the requested attachments could be added',
];
}
return $result;
}
/**
* Stage device-uploaded (base64 payload) attachment entries
*/
private function attachmentAddFromDevice(string $tenantId, string $userId, string $composition, array $uploads, array &$composed): void {
foreach ($uploads as $attachment) {
$identifier = (isset($attachment['identifier']) && is_string($attachment['identifier']) && $attachment['identifier'] !== '')
? $attachment['identifier']
: UUID::v4();
$decoded = base64_decode((string) ($attachment['data'] ?? ''), true);
if ($decoded === false) {
throw new InvalidArgumentException('Attachment payload is not valid base64');
}
$meta = $this->compositionStore->attachmentStageFromBase64(
$data = new BinaryResource(
$attachment['name'] ?? 'unknown.bin',
$attachment['type'] ?? 'application/octet-stream',
$this->stringToGenerator($decoded),
);
$meta = $this->compositionStore->attachmentStageFromStream(
tenantId: $tenantId,
userId: $userId,
compositionId: $composition,
attachmentId: $identifier,
name: $attachment['name'] ?? 'unknown.bin',
type: $attachment['type'] ?? 'application/octet-stream',
data: $attachment['data'] ?? '',
data: $data,
);
$meta['origin'] = 'upload';
$meta['origin'] = 'device';
$composed['attachments'][$meta['identifier']] = $meta;
}
}
private function resolveDocumentsManager(): ?DocumentsManager {
$module = $this->moduleManager->fetch(self::DOCUMENTS_MODULE_HANDLE);
if ($module === null || !$module->enabled()) {
return null;
}
if (!$this->container->has(DocumentsManager::class)) {
return null;
}
return $this->container->get(DocumentsManager::class);
}
/**
* Stage documents-sourced attachment entries
*/
private function attachmentAddFromDocuments(string $tenantId, string $userId, string $composition, array $sourced, array &$composed): array {
$failed = [];
// The documents module is an optional dependency, so we must resolve it
// lazily and handle the case where it is not installed or enabled.
$documentsManager = $this->resolveDocumentsManager();
if ($documentsManager === null) {
foreach ($sourced as $attachment) {
$failed[] = [
'identifier' => (string) ($attachment['source'] ?? ''),
'message' => 'Documents module is not installed or enabled',
];
}
return $failed;
}
$requested = [];
foreach ($sourced as $attachment) {
$rawIdentifier = (string) $attachment['source'];
try {
$parsed = ResourceIdentifier::fromString($rawIdentifier);
} catch (InvalidArgumentException) {
$failed[] = ['identifier' => $rawIdentifier, 'message' => 'Invalid document identifier'];
continue;
}
if (!$parsed instanceof EntityIdentifier) {
$failed[] = ['identifier' => $rawIdentifier, 'message' => 'Invalid document identifier'];
continue;
}
$requested[] = ['attachment' => $attachment, 'identifier' => $parsed];
}
if ($requested === []) {
return $failed;
}
$entities = $documentsManager->entityFetchBulk($tenantId, $userId, ...array_column($requested, 'identifier'));
$entitiesByUrn = [];
foreach ($entities as $entity) {
$entitiesByUrn[$entity->urn()] = $entity;
}
foreach ($requested as ['attachment' => $attachment, 'identifier' => $entityIdentifier]) {
$urn = (string) $entityIdentifier;
$entity = $entitiesByUrn[$urn] ?? null;
if ($entity === null) {
$failed[] = ['identifier' => $urn, 'message' => 'Document not found or access denied'];
continue;
}
$resource = $documentsManager->entityReadStream($tenantId, $userId, $entityIdentifier);
if ($resource === null) {
$failed[] = ['identifier' => $urn, 'message' => 'Document content could not be read'];
continue;
}
$identifier = (isset($attachment['identifier']) && is_string($attachment['identifier']) && $attachment['identifier'] !== '')
? $attachment['identifier']
: UUID::v4();
$properties = $entity->getProperties();
$data = new BinaryResource($properties->getLabel(), $properties->getMime(), $this->streamToGenerator($resource));
$meta = $this->compositionStore->attachmentStageFromStream(
tenantId: $tenantId,
userId: $userId,
compositionId: $composition,
attachmentId: $identifier,
data: $data,
);
$meta['origin'] = 'documents';
$meta['source'] = $urn;
$composed['attachments'][$meta['identifier']] = $meta;
}
$this->compositionStore->compositionSave($tenantId, $userId, $composition, $composed);
$result['disposition'] = 'added';
$result['attachments'] = $composed['attachments'];
return $result;
return $failed;
}
public function attachmentRemove(string $tenantId, string $userId, string $composition, string $identifier): array {
@@ -393,4 +511,48 @@ class CompositionManager {
$result['disposition'] = 'removed';
return $result;
}
/**
* @param array<int,array|string> $entries
* @return array<int,Address>
*/
private function mapAddresses(array $entries): array {
$addresses = [];
foreach ($entries as $entry) {
if (is_array($entry)) {
$address = Address::fromArray($entry);
if ($address->getAddress() !== '') {
$addresses[] = $address;
}
continue;
}
if (is_string($entry) && $entry !== '') {
$address = Address::fromString($entry);
if ($address->getAddress() !== '') {
$addresses[] = $address;
}
}
}
return $addresses;
}
/**
* Wrap an in-memory byte string in a single-chunk Generator.
*/
private function stringToGenerator(string $bytes): \Generator {
yield $bytes;
}
/**
* Wrap a raw PHP stream resource in a Generator yielding chunks, closing it once exhausted.
*/
private function streamToGenerator($resource): \Generator {
while (!feof($resource)) {
yield fread($resource, 65536);
}
fclose($resource);
}
}
+1 -41
View File
@@ -54,13 +54,7 @@ final class CompositionStore {
return true;
}
public function attachmentStageFromStream(
string $tenantId,
string $userId,
string $compositionId,
string $attachmentId,
BinaryResource $data,
): array {
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);
@@ -91,40 +85,6 @@ final class CompositionStore {
return $meta;
}
public function attachmentStageFromBase64(
string $tenantId,
string $userId,
string $compositionId,
string $attachmentId,
string $name,
string $type,
string $data,
): array {
$decoded = base64_decode($data, true);
if ($decoded === false) {
throw new \InvalidArgumentException('Attachment payload is not valid base64');
}
$attachmentDir = $this->attachmentDir($tenantId, $userId, $compositionId);
if (!is_dir($attachmentDir)) {
mkdir($attachmentDir, 0755, true);
}
file_put_contents($attachmentDir . '/' . $attachmentId . '.blob', $decoded);
$metadata = [
'identifier' => $attachmentId,
'composition' => $compositionId,
'name' => $name,
'type' => $type,
'size' => strlen($decoded),
];
file_put_contents($attachmentDir . '/' . $attachmentId . '.meta', json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return $metadata;
}
public function attachmentFetchData(string $tenantId, string $userId, string $compositionId, string $attachmentId): ?string {
$path = $this->attachmentDir($tenantId, $userId, $compositionId) . '/' . $attachmentId . '.blob';
if (!file_exists($path)) {