diff --git a/lib/CompositionManager.php b/lib/CompositionManager.php new file mode 100644 index 0000000..f10c105 --- /dev/null +++ b/lib/CompositionManager.php @@ -0,0 +1,377 @@ + $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' => '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', + 'errorCode' => 'service_not_found', + 'errorMessage' => "Service not found for sender '$senderAddress' or service is disabled", + ]; + } + if ($service instanceof ServiceEntitySubmitInterface === false) { + return [ + 'disposition' => 'error', + 'errorCode' => 'service_not_supported', + 'errorMessage' => "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); + + return $sendResult->jsonSerialize(); + } + + 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; + } +} \ No newline at end of file diff --git a/lib/Controllers/CompositionController.php b/lib/Controllers/CompositionController.php new file mode 100644 index 0000000..47df067 --- /dev/null +++ b/lib/Controllers/CompositionController.php @@ -0,0 +1,157 @@ +tenantIdentity->identifier(); + $userId = $this->userIdentity->identifier(); + + try { + if ($operation === null) { + throw new InvalidArgumentException('Operation must be provided'); + } + + $result = match ($operation) { + 'stage' => $this->stage($tenantId, $userId, $data), + 'patch' => $this->patch($tenantId, $userId, $data), + 'discard' => $this->discard($tenantId, $userId, $data), + 'send' => $this->send($tenantId, $userId, $data), + 'attachment.add' => $this->attachmentAdd($tenantId, $userId, $data), + 'attachment.remove' => $this->attachmentRemove($tenantId, $userId, $data), + default => throw new InvalidArgumentException('Invalid operation: ' . $operation), + }; + + return new JsonResponse([ + 'version' => $version, + 'transaction' => $transaction, + 'operation' => $operation, + 'status' => 'success', + 'data' => $result, + ], JsonResponse::HTTP_OK); + } catch (Throwable $throwable) { + $this->logger->error('Mail draft request failed', ['exception' => $throwable]); + + return new JsonResponse([ + 'version' => $version, + 'transaction' => $transaction, + 'operation' => $operation, + 'status' => 'error', + 'data' => [ + 'code' => $throwable->getCode(), + 'message' => $throwable->getMessage(), + ], + ], JsonResponse::HTTP_INTERNAL_SERVER_ERROR); + } + } + + private function stage(string $tenantId, string $userId, array $data): array { + if (!isset($data['identifier']) || !is_string($data['identifier']) || $data['identifier'] === '') { + throw new InvalidArgumentException('Invalid parameter: identifier must be a non-empty string'); + } + if (!isset($data['action']) || !is_string($data['action'])) { + throw new InvalidArgumentException('Invalid parameter: action must be a string'); + } + if (!in_array($data['action'], ['fresh', 'reply', 'forward'], true)) { + throw new InvalidArgumentException('Invalid parameter: action must be one of fresh, reply, forward'); + } + if (!isset($data['sender']) || !is_array($data['sender'])) { + throw new InvalidArgumentException('Invalid parameter: sender must be an array'); + } + if (!isset($data['message']) || !is_array($data['message'])) { + throw new InvalidArgumentException('Invalid parameter: message must be an array'); + } + if (($data['action'] === 'reply' || $data['action'] === 'forward') && (!isset($data['source']) || !is_string($data['source']) || $data['source'] === '')) { + throw new InvalidArgumentException('Invalid parameter: source must be a non-empty string for reply or forward action'); + } + + return $this->compositionManager->stage($tenantId, $userId, $data['identifier'], $data['action'], $data['sender'], $data['message'], $data['source'] ?? null); + } + + private function patch(string $tenantId, string $userId, array $data): array { + if (!isset($data['identifier']) || !is_string($data['identifier']) || $data['identifier'] === '') { + throw new InvalidArgumentException('Invalid parameter: identifier must be a non-empty string'); + } + if (!isset($data['sender']) || !is_array($data['sender'])) { + throw new InvalidArgumentException('Invalid parameter: sender must be an array'); + } + if (!isset($data['message']) || !is_array($data['message'])) { + throw new InvalidArgumentException('Invalid parameter: message must be an array'); + } + + return $this->compositionManager->patch($tenantId, $userId, $data['identifier'], $data); + } + private function discard(string $tenantId, string $userId, array $data): array { + if (!isset($data['identifier']) || !is_string($data['identifier']) || $data['identifier'] === '') { + throw new InvalidArgumentException('Invalid parameter: identifier must be a non-empty string'); + } + + return $this->compositionManager->discard($tenantId, $userId, $data['identifier']); + } + + private function send(string $tenantId, string $userId, array $data): array { + if (!isset($data['identifier']) || !is_string($data['identifier']) || $data['identifier'] === '') { + throw new InvalidArgumentException('Invalid parameter: identifier must be a non-empty string'); + } + if (!isset($data['sender']) || !is_array($data['sender'])) { + throw new InvalidArgumentException('Invalid parameter: sender must be an array'); + } + if (!isset($data['message']) || !is_array($data['message'])) { + throw new InvalidArgumentException('Invalid parameter: message must be an array'); + } + if (!isset($data['attachments']) || !is_array($data['attachments'])) { + throw new InvalidArgumentException('Invalid parameter: attachments must be an array'); + } + + return $this->compositionManager->send($tenantId, $userId, $data['identifier'], $data['sender'], $data['message'], $data['attachments']); + } + + private function attachmentAdd(string $tenantId, string $userId, array $data): array { + if (!isset($data['composition']) || !is_string($data['composition']) || $data['composition'] === '') { + throw new InvalidArgumentException('Invalid parameter: composition must be a non-empty string'); + } + if (!isset($data['attachments']) || !is_array($data['attachments'])) { + throw new InvalidArgumentException('Invalid parameter: attachments must be an array'); + } + + return $this->compositionManager->attachmentAdd($tenantId, $userId, $data['composition'], ...$data['attachments']); + } + + private function attachmentRemove(string $tenantId, string $userId, array $data): array { + if (!isset($data['composition']) || !is_string($data['composition']) || $data['composition'] === '') { + throw new InvalidArgumentException('Invalid parameter: composition must be a non-empty string'); + } + if (!isset($data['identifier']) || !is_string($data['identifier']) || $data['identifier'] === '') { + throw new InvalidArgumentException('Invalid parameter: identifier must be a non-empty string'); + } + + return $this->compositionManager->attachmentRemove($tenantId, $userId, $data['composition'], $data['identifier']); + } +} \ No newline at end of file diff --git a/lib/Stores/CompositionStore.php b/lib/Stores/CompositionStore.php new file mode 100644 index 0000000..edc88ff --- /dev/null +++ b/lib/Stores/CompositionStore.php @@ -0,0 +1,176 @@ +storagePath = $this->rootDir . '/var/cache/mail/composer'; + } + + public function compositionFetch(string $tenantId, string $userId, string $draftId): ?array { + $draftDir = $this->draftDir($tenantId, $userId, $draftId); + if (!is_dir($draftDir)) { + return null; + } + + $messagePath = $draftDir . '/' . self::COMPOSITION_FILENAME; + if (!file_exists($messagePath)) { + return null; + } + + $decoded = json_decode((string)file_get_contents($messagePath), true); + return is_array($decoded) ? $decoded : null; + } + + public function compositionSave(string $tenantId, string $userId, string $draftId, array $snapshot): array { + $draftDir = $this->draftDir($tenantId, $userId, $draftId); + if (!is_dir($draftDir)) { + mkdir($draftDir, 0755, true); + } + + file_put_contents($draftDir . '/' . self::COMPOSITION_FILENAME, json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + return $snapshot; + } + + public function compositionDiscard(string $tenantId, string $userId, string $draftId): bool { + $draftDir = $this->draftDir($tenantId, $userId, $draftId); + if (!is_dir($draftDir)) { + return false; + } + + $this->deleteDir($draftDir); + return true; + } + + 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); + } + + $contentPath = $attachmentDir . '/' . $attachmentId . '.blob'; + $handle = fopen($contentPath, 'wb'); + $size = 0; + + foreach ($data->stream() as $chunk) { + $chunkString = (string)$chunk; + $size += strlen($chunkString); + fwrite($handle, $chunkString); + } + + fclose($handle); + + $meta = [ + 'identifier' => $attachmentId, + 'composition' => $compositionId, + 'name' => $data->filename(), + 'type' => $data->mimeType(), + 'size' => $size, + ]; + + file_put_contents($attachmentDir . '/' . $attachmentId . '.meta', json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + 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)) { + return null; + } + + return file_get_contents($path) ?: null; + } + + public function fetchAttachmentMeta(string $tenantId, string $userId, string $compositionId, string $attachmentId): ?array { + $path = $this->attachmentDir($tenantId, $userId, $compositionId) . '/' . $attachmentId . '.meta'; + if (!file_exists($path)) { + return null; + } + + $decoded = json_decode((string)file_get_contents($path), true); + return is_array($decoded) ? $decoded : null; + } + + private function attachmentDir(string $tenantId, string $userId, string $draftId): string { + return $this->draftDir($tenantId, $userId, $draftId) . '/attachments'; + } + + private function draftDir(string $tenantId, string $userId, string $draftId): string { + return $this->storagePath . '/' . $tenantId . '/' . $userId . '/' . $draftId; + } + + private function deleteDir(string $path): void { + if (!is_dir($path)) { + return; + } + + foreach (scandir($path) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $child = $path . '/' . $entry; + if (is_dir($child)) { + $this->deleteDir($child); + continue; + } + + unlink($child); + } + + rmdir($path); + } +} \ No newline at end of file diff --git a/src/components/CreateFolderDialog.vue b/src/components/CreateFolderDialog.vue index 5542f01..83a321c 100644 --- a/src/components/CreateFolderDialog.vue +++ b/src/components/CreateFolderDialog.vue @@ -91,7 +91,7 @@ const handleCancel = () => {
Account
- {{ service.label || service.primaryAddress || 'Mail Account' }} + {{ service.label || service.primaryAddress?.format() || 'Mail Account' }}
diff --git a/src/components/DeleteFolderDialog.vue b/src/components/DeleteFolderDialog.vue index afa3100..c837f46 100644 --- a/src/components/DeleteFolderDialog.vue +++ b/src/components/DeleteFolderDialog.vue @@ -64,7 +64,7 @@ const handleCancel = () => {
Account
- {{ service.label || service.primaryAddress || 'Mail Account' }} + {{ service.label || service.primaryAddress?.format() || 'Mail Account' }}
diff --git a/src/components/FolderPageView.vue b/src/components/FolderPageView.vue index ba2a308..8b664e7 100644 --- a/src/components/FolderPageView.vue +++ b/src/components/FolderPageView.vue @@ -187,7 +187,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null v-bind="activatorProps" class="account-header-item" :title="group.service.label || 'Mail Account'" - :subtitle="group.service.primaryAddress || undefined" + :subtitle="group.service.primaryAddress?.address || undefined" >