$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 $entries * @return array */ 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; } }