Files
mail/lib/CompositionManager.php
2026-06-16 23:41:02 -04:00

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;
}
}