0af38cf50c
Signed-off-by: Sebastian <krupinski01@gmail.com>
396 lines
15 KiB
PHP
396 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace KTXM\Mail;
|
|
|
|
use InvalidArgumentException;
|
|
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\Identifier\ResourceIdentifier;
|
|
use KTXF\Utile\UUID;
|
|
use KTXM\Mail\Stores\CompositionStore;
|
|
use KTXM\MailManager\Manager as MailManager;
|
|
|
|
class CompositionManager {
|
|
|
|
public function __construct(
|
|
private readonly CompositionStore $compositionStore,
|
|
private readonly MailManager $mailManager,
|
|
) {}
|
|
|
|
public function stage(string $tenantId, string $userId, string $identifier, string $action, array $sender, array $message, ?string $source = null): array {
|
|
if ($identifier === '') {
|
|
throw new InvalidArgumentException('Draft identifier is required');
|
|
}
|
|
|
|
$snapshot = [
|
|
'identifier' => $identifier,
|
|
'action' => $action,
|
|
'source' => $source,
|
|
'revision' => 1,
|
|
'disposition' => 'staged',
|
|
'sender' => $sender,
|
|
'message' => $message,
|
|
'attachments' => [],
|
|
];
|
|
|
|
$this->compositionStore->compositionSave($tenantId, $userId, $identifier, $snapshot);
|
|
|
|
if ($action === 'forward') {
|
|
$sourceIndentifier = ResourceIdentifier::fromString($source);
|
|
if ($sourceIndentifier === null) {
|
|
throw new InvalidArgumentException('Invalid source identifier');
|
|
}
|
|
$entities = $this->mailManager->entityFetchBulk($tenantId, $userId, $sourceIndentifier);
|
|
if (count($entities) === 0) {
|
|
throw new InvalidArgumentException('Source message not found');
|
|
}
|
|
// retrieve the attachments from the source message
|
|
$attachments = reset($entities)->getProperties()->getAttachments();
|
|
// For each attachment, we download the content and stage it in the composition store
|
|
foreach ($attachments as $attachment) {
|
|
$contentId = $attachment->getContentId();
|
|
$isInline = $attachment->getDisposition() === 'inline';
|
|
// target part
|
|
$targetPart = [
|
|
'partId' => $attachment->getId(),
|
|
'blobId' => $attachment->getBlobId(),
|
|
'cid' => $contentId,
|
|
'cId' => $contentId,
|
|
];
|
|
// retrieve the attachment content as a stream
|
|
$data = $this->mailManager->entityDownload($tenantId, $userId, $sourceIndentifier, $targetPart);
|
|
// stage the attachment in the composition store
|
|
$meta = $this->compositionStore->attachmentStageFromStream(
|
|
tenantId: $tenantId,
|
|
userId: $userId,
|
|
compositionId: $identifier,
|
|
attachmentId: UUID::v4(),
|
|
data: $data,
|
|
);
|
|
// supplement the attachment metadata with source information
|
|
$meta['origin'] = 'source';
|
|
$meta['source'] = $source;
|
|
$meta['partId'] = $attachment->getId();
|
|
$meta['blobId'] = $attachment->getBlobId();
|
|
$meta['cid'] = $contentId;
|
|
$meta['contentId'] = $contentId;
|
|
$meta['disposition'] = $attachment->getDisposition();
|
|
$meta['inline'] = $isInline;
|
|
$meta['name'] = $attachment->getName() ?? $meta['name'];
|
|
$meta['type'] = $attachment->getType() ?? $meta['type'];
|
|
$meta['size'] = $attachment->getSize() ?? $meta['size'];
|
|
$snapshot['attachments'][$meta['identifier']] = $meta;
|
|
}
|
|
// update the snapshot with the staged attachments
|
|
$this->compositionStore->compositionSave($tenantId, $userId, $identifier, $snapshot);
|
|
}
|
|
|
|
return $snapshot;
|
|
}
|
|
|
|
public function patch(string $tenantId, string $userId, string $identifier, array $data): array {
|
|
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $identifier);
|
|
|
|
if ($composed === null) {
|
|
$result['disposition'] = 'error';
|
|
$result['error'] = [
|
|
'type' => 'composition_not_found',
|
|
'message' => 'Composition not found',
|
|
];
|
|
return $result;
|
|
}
|
|
|
|
if (!isset($data['revision']) || !is_int($data['revision']) || $data['revision'] < $composed['revision']) {
|
|
$result['disposition'] = 'error';
|
|
$result['error'] = [
|
|
'type' => 'revision_mismatch',
|
|
'message' => 'Revision mismatch',
|
|
'currentRevision' => $composed['revision'],
|
|
];
|
|
return $result;
|
|
}
|
|
|
|
// Apply the patch data to the composed message
|
|
$composed['revision'] = $data['revision'];
|
|
$composed['sender'] = $data['sender'] ?? [];
|
|
$composed['message'] = $data['message'] ?? [];
|
|
|
|
$this->compositionStore->compositionSave($tenantId, $userId, $identifier, $composed);
|
|
|
|
$result = [
|
|
'identifier' => $identifier,
|
|
'disposition' => 'patched',
|
|
];
|
|
return $result;
|
|
}
|
|
|
|
public function discard(string $tenantId, string $userId, string $identifier): array {
|
|
return [
|
|
'identifier' => $identifier,
|
|
'disposition' => $this->compositionStore->compositionDiscard($tenantId, $userId, $identifier),
|
|
];
|
|
}
|
|
|
|
public function send(string $tenantId, string $userId, string $identifier, array $sender, array $message, array $attachments): array {
|
|
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $identifier);
|
|
|
|
if ($composed === null) {
|
|
$result['disposition'] = 'error';
|
|
$result['error'] = [
|
|
'type' => 'composition_not_found',
|
|
'message' => 'Composition not found',
|
|
];
|
|
return $result;
|
|
}
|
|
|
|
// store the message and sender information before sending
|
|
$composed['sender'] = $sender;
|
|
$composed['message'] = $message;
|
|
$this->compositionStore->compositionSave($tenantId, $userId, $identifier, $composed);
|
|
|
|
// validate the sender address before attempting to send the message
|
|
if ($sender['address'] === '' || !filter_var($sender['address'], FILTER_VALIDATE_EMAIL)) {
|
|
$result['disposition'] = 'error';
|
|
$result['error'] = [
|
|
'type' => 'composition_invalid_sender',
|
|
'message' => 'Invalid sender address',
|
|
];
|
|
return $result;
|
|
}
|
|
$senderAddress = $sender['address'];
|
|
$senderObject = Address::fromArray($sender);
|
|
|
|
// resolve submit-capable service for sender
|
|
$service = $this->mailManager->serviceFindByAddress($tenantId, $userId, $senderAddress);
|
|
if ($service === null || $service->getEnabled() === false) {
|
|
return [
|
|
'disposition' => 'error',
|
|
'error' => [
|
|
'type' => 'service_not_found',
|
|
'message' => "Service not found for sender '$senderAddress' or service is disabled",
|
|
],
|
|
];
|
|
}
|
|
if ($service instanceof ServiceEntitySubmitInterface === false) {
|
|
return [
|
|
'disposition' => 'error',
|
|
'error' => [
|
|
'type' => 'service_not_supported',
|
|
'message' => "Service '{$service->identifier()}' does not support entity submission",
|
|
],
|
|
];
|
|
}
|
|
|
|
$source = null;
|
|
$properties = $this->buildMessageProperties(
|
|
service: $service,
|
|
sender: $senderObject,
|
|
message: $message,
|
|
attachments: $attachments,
|
|
composed: $composed,
|
|
tenantId: $tenantId,
|
|
userId: $userId,
|
|
compositionId: $identifier,
|
|
);
|
|
|
|
$sendResult = $service->entitySubmit($senderObject, $source, $properties);
|
|
|
|
if ($sendResult->disposition === EntitySubmitResult::DISPOSITION_ERROR) {
|
|
return [
|
|
'identifier' => $identifier,
|
|
'disposition' => 'error',
|
|
'error' => [
|
|
'type' => 'service_submission_error',
|
|
'message' => $sendResult->errorMessage ?? 'An unknown error occurred during submission',
|
|
],
|
|
];
|
|
}
|
|
|
|
return [
|
|
'identifier' => $identifier,
|
|
'disposition' => 'sent'
|
|
];
|
|
}
|
|
|
|
private function buildMessageProperties(
|
|
ServiceEntitySubmitInterface $service,
|
|
AddressInterface $sender,
|
|
array $message,
|
|
array $attachments,
|
|
array $composed,
|
|
string $tenantId,
|
|
string $userId,
|
|
string $compositionId,
|
|
): MessagePropertiesMutableInterface {
|
|
$properties = $service->entityFresh()->getProperties();
|
|
$bodyTextPlain = isset($message['body']['text']) ? (string)$message['body']['text'] : '';
|
|
$bodyTextHtml = isset($message['body']['html']) ? (string)$message['body']['html'] : '';
|
|
|
|
$properties->setFrom($sender);
|
|
|
|
$to = $this->mapAddresses($message['to'] ?? []);
|
|
if ($to !== []) {
|
|
$properties->setTo(...$to);
|
|
}
|
|
|
|
$cc = $this->mapAddresses($message['cc'] ?? []);
|
|
if ($cc !== []) {
|
|
$properties->setCc(...$cc);
|
|
}
|
|
|
|
$bcc = $this->mapAddresses($message['bcc'] ?? []);
|
|
if ($bcc !== []) {
|
|
$properties->setBcc(...$bcc);
|
|
}
|
|
|
|
$replyTo = $this->mapAddresses($message['replyTo'] ?? []);
|
|
if ($replyTo !== []) {
|
|
$properties->setReplyTo(...$replyTo);
|
|
}
|
|
|
|
$properties->setSubject((string)($message['subject'] ?? ''));
|
|
$properties->setBodyTextPlain($bodyTextPlain);
|
|
$properties->setBodyTextHtml($bodyTextHtml);
|
|
|
|
if (isset($message['flags']) && is_array($message['flags'])) {
|
|
$properties->setFlags($message['flags']);
|
|
}
|
|
|
|
$attachmentObjects = [];
|
|
foreach ($attachments as $attachment) {
|
|
if (!isset($attachment['identifier']) || !is_string($attachment['identifier']) || $attachment['identifier'] === '') {
|
|
continue;
|
|
}
|
|
|
|
$composedAttachment = $composed['attachments'][$attachment['identifier']] ?? null;
|
|
if (!is_array($composedAttachment)) {
|
|
continue;
|
|
}
|
|
|
|
$attachmentObjects[] = MessagePart::fromArray([
|
|
'partId' => $attachment['identifier'],
|
|
'blobId' => $attachment['blobId'] ?? $composedAttachment['blobId'] ?? null,
|
|
'size' => $composedAttachment['size'] ?? null,
|
|
'name' => $composedAttachment['name'] ?? 'unknown.bin',
|
|
'type' => $composedAttachment['type'] ?? 'application/octet-stream',
|
|
'disposition' => (($attachment['inline'] ?? $composedAttachment['inline'] ?? false) === true) ? 'inline' : 'attachment',
|
|
'content' => $this->compositionStore->attachmentFetchData($tenantId, $userId, $compositionId, $attachment['identifier']),
|
|
'cid' => $attachment['contentId'] ?? $attachment['cid'] ?? $composedAttachment['contentId'] ?? $composedAttachment['cid'] ?? null,
|
|
]);
|
|
}
|
|
|
|
if ($attachmentObjects !== []) {
|
|
$properties->setAttachments(...$attachmentObjects);
|
|
}
|
|
|
|
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' => [],
|
|
];
|
|
|
|
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $composition);
|
|
if ($composed === null) {
|
|
$result['disposition'] = 'error';
|
|
$result['error'] = [
|
|
'type' => 'composition_not_found',
|
|
'message' => 'Composition not found',
|
|
];
|
|
return $result;
|
|
}
|
|
|
|
foreach ($attachments as $attachment) {
|
|
if (!isset($attachment['identifier']) || !is_string($attachment['identifier']) || $attachment['identifier'] === '') {
|
|
$identifier = UUID::v4();
|
|
} else {
|
|
$identifier = $attachment['identifier'];
|
|
}
|
|
|
|
$meta = $this->compositionStore->attachmentStageFromBase64(
|
|
tenantId: $tenantId,
|
|
userId: $userId,
|
|
compositionId: $composition,
|
|
attachmentId: $identifier,
|
|
name: $attachment['name'] ?? 'unknown.bin',
|
|
type: $attachment['type'] ?? 'application/octet-stream',
|
|
data: $attachment['data'] ?? '',
|
|
);
|
|
$meta['origin'] = 'upload';
|
|
$composed['attachments'][$meta['identifier']] = $meta;
|
|
}
|
|
|
|
$this->compositionStore->compositionSave($tenantId, $userId, $composition, $composed);
|
|
|
|
$result['disposition'] = 'added';
|
|
$result['attachments'] = $composed['attachments'];
|
|
return $result;
|
|
}
|
|
|
|
public function attachmentRemove(string $tenantId, string $userId, string $composition, string $identifier): array {
|
|
$result = [
|
|
'composition' => $composition,
|
|
'identifier' => $identifier,
|
|
];
|
|
|
|
$composed = $this->compositionStore->compositionFetch($tenantId, $userId, $composition);
|
|
if ($composed === null) {
|
|
$result['disposition'] = 'error';
|
|
$result['error'] = [
|
|
'type' => 'composition_not_found',
|
|
'message' => 'Composition not found',
|
|
];
|
|
return $result;
|
|
}
|
|
if (!isset($composed['attachments'][$identifier])) {
|
|
$result['disposition'] = 'error';
|
|
$result['error'] = [
|
|
'type' => 'attachment_not_found',
|
|
'message' => 'Attachment not found',
|
|
];
|
|
return $result;
|
|
}
|
|
|
|
unset($composed['attachments'][$identifier]);
|
|
$this->compositionStore->compositionSave($tenantId, $userId, $composition, $composed);
|
|
|
|
$result['disposition'] = 'removed';
|
|
return $result;
|
|
}
|
|
} |