feat: add attach from documents
Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
+207
-45
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user