feat: mail composition #63
@@ -0,0 +1,377 @@
|
|||||||
|
<?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\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' => '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<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXM\Mail\Controllers;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use KTXC\Http\Response\JsonResponse;
|
||||||
|
use KTXC\Http\Response\Response;
|
||||||
|
use KTXC\SessionIdentity;
|
||||||
|
use KTXC\SessionTenant;
|
||||||
|
use KTXF\Controller\ControllerAbstract;
|
||||||
|
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||||
|
use KTXM\Mail\CompositionManager;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class CompositionController extends ControllerAbstract {
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly SessionTenant $tenantIdentity,
|
||||||
|
private readonly SessionIdentity $userIdentity,
|
||||||
|
private readonly CompositionManager $compositionManager,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[AuthenticatedRoute('/compose/v1', name: 'mail.compose.v1', methods: ['POST'])]
|
||||||
|
public function index(
|
||||||
|
int $version,
|
||||||
|
string $transaction,
|
||||||
|
string|null $operation = null,
|
||||||
|
array|null $data = null,
|
||||||
|
): Response {
|
||||||
|
$tenantId = $this->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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXM\Mail\Stores;
|
||||||
|
|
||||||
|
use DI\Attribute\Inject;
|
||||||
|
use KTXF\Resource\BinaryResource;
|
||||||
|
|
||||||
|
final class CompositionStore {
|
||||||
|
|
||||||
|
private const COMPOSITION_FILENAME = 'composition.json';
|
||||||
|
|
||||||
|
private string $storagePath;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
#[Inject('rootDir')] private readonly string $rootDir,
|
||||||
|
) {
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,7 +91,7 @@ const handleCancel = () => {
|
|||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="text-caption text-medium-emphasis">Account</div>
|
<div class="text-caption text-medium-emphasis">Account</div>
|
||||||
<div class="text-body-2">
|
<div class="text-body-2">
|
||||||
{{ service.label || service.primaryAddress || 'Mail Account' }}
|
{{ service.label || service.primaryAddress?.format() || 'Mail Account' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const handleCancel = () => {
|
|||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="text-caption text-medium-emphasis">Account</div>
|
<div class="text-caption text-medium-emphasis">Account</div>
|
||||||
<div class="text-body-2">
|
<div class="text-body-2">
|
||||||
{{ service.label || service.primaryAddress || 'Mail Account' }}
|
{{ service.label || service.primaryAddress?.format() || 'Mail Account' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
|
|||||||
v-bind="activatorProps"
|
v-bind="activatorProps"
|
||||||
class="account-header-item"
|
class="account-header-item"
|
||||||
:title="group.service.label || 'Mail Account'"
|
:title="group.service.label || 'Mail Account'"
|
||||||
:subtitle="group.service.primaryAddress || undefined"
|
:subtitle="group.service.primaryAddress?.address || undefined"
|
||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<v-icon icon="mdi-email-outline" />
|
<v-icon icon="mdi-email-outline" />
|
||||||
@@ -348,7 +348,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
|
|||||||
<v-list-item
|
<v-list-item
|
||||||
class="account-header-item"
|
class="account-header-item"
|
||||||
:title="group.service.label || 'Mail Account'"
|
:title="group.service.label || 'Mail Account'"
|
||||||
:subtitle="group.service.primaryAddress || undefined"
|
:subtitle="group.service.primaryAddress?.address || undefined"
|
||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<v-icon icon="mdi-email-outline" />
|
<v-icon icon="mdi-email-outline" />
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ const handleConfirm = () => {
|
|||||||
<v-list-item
|
<v-list-item
|
||||||
class="account-header-item account-header-static"
|
class="account-header-item account-header-static"
|
||||||
:title="group.service.label || 'Mail Account'"
|
:title="group.service.label || 'Mail Account'"
|
||||||
:subtitle="group.service.primaryAddress || undefined"
|
:subtitle="group.service.primaryAddress?.address || undefined"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-icon icon="mdi-email-outline" />
|
<v-icon icon="mdi-email-outline" />
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const getServiceFolders = (service: ServiceObject): CollectionObject[] => {
|
|||||||
v-bind="activatorProps"
|
v-bind="activatorProps"
|
||||||
class="account-header-item"
|
class="account-header-item"
|
||||||
:title="group.service.label || 'Mail Account'"
|
:title="group.service.label || 'Mail Account'"
|
||||||
:subtitle="group.service.primaryAddress || undefined"
|
:subtitle="group.service.primaryAddress?.address || undefined"
|
||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<v-icon icon="mdi-email-outline" />
|
<v-icon icon="mdi-email-outline" />
|
||||||
@@ -116,7 +116,7 @@ const getServiceFolders = (service: ServiceObject): CollectionObject[] => {
|
|||||||
<v-list-item
|
<v-list-item
|
||||||
class="account-header-item account-header-static"
|
class="account-header-item account-header-static"
|
||||||
:title="group.service.label || 'Mail Account'"
|
:title="group.service.label || 'Mail Account'"
|
||||||
:subtitle="group.service.primaryAddress || undefined"
|
:subtitle="group.service.primaryAddress?.address || undefined"
|
||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<v-icon icon="mdi-email-outline" />
|
<v-icon icon="mdi-email-outline" />
|
||||||
|
|||||||
@@ -8,18 +8,20 @@ import Underline from '@tiptap/extension-underline'
|
|||||||
import TextAlign from '@tiptap/extension-text-align'
|
import TextAlign from '@tiptap/extension-text-align'
|
||||||
import Placeholder from '@tiptap/extension-placeholder'
|
import Placeholder from '@tiptap/extension-placeholder'
|
||||||
import { EntityObject } from '@MailManager/models/entity'
|
import { EntityObject } from '@MailManager/models/entity'
|
||||||
import type { CollectionObject } from '@MailManager/models'
|
import type { CollectionObject, MessageAddressObject } from '@MailManager/models'
|
||||||
import { useMailStore } from '@/stores/mailStore'
|
import { useMailStore } from '@/stores/mailStore'
|
||||||
import type { MessageAddressInterface } from '@MailManager/types/message'
|
import { useMailCompositionStore } from '@/stores/mailCompositionStore'
|
||||||
import { ComposerMode } from '@/types/composer'
|
import { ComposerMode } from '@/types/composer'
|
||||||
import ComposerToolbar from '@/components/composer/ComposerToolbar.vue'
|
import ComposerToolbar from '@/components/composer/ComposerToolbar.vue'
|
||||||
|
import ComposerSender from '@/components/composer/ComposerSender.vue'
|
||||||
import ComposerRecipients from '@/components/composer/ComposerRecipients.vue'
|
import ComposerRecipients from '@/components/composer/ComposerRecipients.vue'
|
||||||
|
import ComposerAttachments from '@/components/composer/ComposerAttachments.vue'
|
||||||
import ComposerEditor from '@/components/composer/ComposerEditor.vue'
|
import ComposerEditor from '@/components/composer/ComposerEditor.vue'
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
interface Props {
|
interface Props {
|
||||||
mode: ComposerMode
|
mode: ComposerMode
|
||||||
source?: EntityObject | MessageAddressInterface | null
|
source?: EntityObject | MessageAddressObject | null
|
||||||
folder?: CollectionObject | null
|
folder?: CollectionObject | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,22 +33,52 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const mailStore = useMailStore()
|
const mailStore = useMailStore()
|
||||||
|
const compositionStore = useMailCompositionStore()
|
||||||
const {
|
const {
|
||||||
composerSending: sending,
|
composerSending: sending,
|
||||||
composerSaving: saving,
|
|
||||||
composerLastSaved: lastSaved,
|
|
||||||
} = storeToRefs(mailStore)
|
} = storeToRefs(mailStore)
|
||||||
|
|
||||||
|
const {
|
||||||
|
activeDraft,
|
||||||
|
saving,
|
||||||
|
stageStatus,
|
||||||
|
} = storeToRefs(compositionStore)
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const to = ref<string[]>([])
|
|
||||||
const cc = ref<string[]>([])
|
|
||||||
const bcc = ref<string[]>([])
|
|
||||||
const subject = ref('')
|
|
||||||
const showCc = ref(false)
|
const showCc = ref(false)
|
||||||
const showBcc = ref(false)
|
const showBcc = ref(false)
|
||||||
|
const applyingDraftToEditor = ref(false)
|
||||||
|
|
||||||
// Auto-save timer
|
const sender = computed({
|
||||||
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null
|
get: () => activeDraft.value?.sender ?? null,
|
||||||
|
set: value => {
|
||||||
|
if (value) {
|
||||||
|
compositionStore.updateSender(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const to = computed({
|
||||||
|
get: () => activeDraft.value?.message.to ?? [],
|
||||||
|
set: value => compositionStore.updateRecipients('to', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const cc = computed({
|
||||||
|
get: () => activeDraft.value?.message.cc ?? [],
|
||||||
|
set: value => compositionStore.updateRecipients('cc', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const bcc = computed({
|
||||||
|
get: () => activeDraft.value?.message.bcc ?? [],
|
||||||
|
set: value => compositionStore.updateRecipients('bcc', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const subject = computed({
|
||||||
|
get: () => activeDraft.value?.message.subject ?? '',
|
||||||
|
set: value => compositionStore.updateSubject(value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const attachments = computed(() => activeDraft.value?.attachments ?? {})
|
||||||
|
|
||||||
// Initialize Tiptap editor
|
// Initialize Tiptap editor
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
@@ -71,61 +103,6 @@ const editor = useEditor({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function resetComposerFields() {
|
|
||||||
to.value = []
|
|
||||||
cc.value = []
|
|
||||||
bcc.value = []
|
|
||||||
subject.value = ''
|
|
||||||
showCc.value = false
|
|
||||||
showBcc.value = false
|
|
||||||
editor.value?.commands.setContent('')
|
|
||||||
}
|
|
||||||
|
|
||||||
function initializeComposerFromProps() {
|
|
||||||
mailStore.resetComposerState()
|
|
||||||
resetComposerFields()
|
|
||||||
|
|
||||||
if (props.mode === ComposerMode.Fresh) {
|
|
||||||
if (props.source && 'address' in props.source) {
|
|
||||||
// If source is an email address, pre-fill the "To" field
|
|
||||||
to.value = [props.source.address]
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.source instanceof EntityObject == false) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceMessage = props.source.properties
|
|
||||||
const originalSubject = sourceMessage.subject || ''
|
|
||||||
const originalBody = sourceMessage.getHtmlContent() || sourceMessage.getTextContent() || ''
|
|
||||||
const senderName = sourceMessage.from?.label || sourceMessage.from?.address || 'Unknown'
|
|
||||||
const sentAt = sourceMessage.sent || props.source.created || ''
|
|
||||||
const sentLabel = sentAt ? new Date(sentAt).toLocaleString() : 'an unknown time'
|
|
||||||
|
|
||||||
if (props.mode === ComposerMode.Reply) {
|
|
||||||
const fromEmail = sourceMessage.replyTo?.[0]?.address || sourceMessage.from?.address
|
|
||||||
to.value = fromEmail ? [fromEmail] : []
|
|
||||||
subject.value = /^Re:/i.test(originalSubject)
|
|
||||||
? originalSubject
|
|
||||||
: `Re: ${originalSubject}`
|
|
||||||
editor.value?.commands.setContent(
|
|
||||||
`<p><br></p><p>---------- Original message ---------</p><p>From: ${senderName}</p><p>Date: ${sentLabel}</p><p>Subject: ${originalSubject}</p><blockquote>${originalBody}</blockquote>`,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.mode === ComposerMode.Forward) {
|
|
||||||
subject.value = /^Fwd:/i.test(originalSubject)
|
|
||||||
? originalSubject
|
|
||||||
: `Fwd: ${originalSubject}`
|
|
||||||
editor.value?.commands.setContent(
|
|
||||||
`<p><br></p><p>---------- Forwarded message ---------</p><p>From: ${senderName}</p><p>Date: ${sentLabel}</p><p>Subject: ${originalSubject}</p><blockquote>${originalBody}</blockquote>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[() => props.mode, () => props.source, () => editor.value],
|
[() => props.mode, () => props.source, () => editor.value],
|
||||||
([, , currentEditor]) => {
|
([, , currentEditor]) => {
|
||||||
@@ -133,7 +110,13 @@ watch(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeComposerFromProps()
|
compositionStore.openDraft(props.mode, props.source)
|
||||||
|
showCc.value = (activeDraft.value?.message.cc.length ?? 0) > 0
|
||||||
|
showBcc.value = (activeDraft.value?.message.bcc.length ?? 0) > 0
|
||||||
|
|
||||||
|
applyingDraftToEditor.value = true
|
||||||
|
currentEditor.commands.setContent(activeDraft.value?.message.body.html || '')
|
||||||
|
applyingDraftToEditor.value = false
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
@@ -143,97 +126,52 @@ const canSend = computed(() => {
|
|||||||
return to.value.length > 0 && subject.value.trim().length > 0
|
return to.value.length > 0 && subject.value.trim().length > 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const saveStatus = computed(() => {
|
// Watch editor content changes
|
||||||
if (saving.value) return 'Saving...'
|
watch(editor, currentEditor => {
|
||||||
if (lastSaved.value) {
|
if (!currentEditor) {
|
||||||
const seconds = Math.floor((Date.now() - lastSaved.value.getTime()) / 1000)
|
|
||||||
if (seconds < 60) return 'Saved just now'
|
|
||||||
if (seconds < 3600) return `Saved ${Math.floor(seconds / 60)} min ago`
|
|
||||||
return `Saved at ${lastSaved.value.toLocaleTimeString()}`
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// Auto-save function
|
|
||||||
const saveDraft = async () => {
|
|
||||||
if (saving.value || sending.value) return
|
|
||||||
if (!props.folder) return
|
|
||||||
|
|
||||||
// Don't save if completely empty
|
|
||||||
if (to.value.length === 0 && subject.value.trim().length === 0 && !editor.value?.getText().trim()) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const handleUpdate = () => {
|
||||||
await mailStore.saveComposerDraft(props.folder, {
|
if (applyingDraftToEditor.value) {
|
||||||
to: to.value,
|
return
|
||||||
cc: cc.value,
|
|
||||||
bcc: bcc.value,
|
|
||||||
subject: subject.value,
|
|
||||||
body: {
|
|
||||||
html: editor.value?.getHTML() || '',
|
|
||||||
text: editor.value?.getText() || '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Mail][Composer] Failed to save draft:', error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch for changes and trigger auto-save
|
compositionStore.updateBody({
|
||||||
const scheduleAutoSave = () => {
|
html: currentEditor.getHTML(),
|
||||||
if (autoSaveTimer) {
|
text: currentEditor.getText(),
|
||||||
clearTimeout(autoSaveTimer)
|
|
||||||
}
|
|
||||||
|
|
||||||
autoSaveTimer = setTimeout(() => {
|
|
||||||
saveDraft()
|
|
||||||
}, 30000) // 30 seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
watch([to, cc, bcc, subject], () => {
|
|
||||||
scheduleAutoSave()
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
// Watch editor content changes
|
|
||||||
if (editor.value) {
|
|
||||||
editor.value.on('update', () => {
|
|
||||||
scheduleAutoSave()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentEditor.on('update', handleUpdate)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
currentEditor.off('update', handleUpdate)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (autoSaveTimer) {
|
void compositionStore.flushSave()
|
||||||
clearTimeout(autoSaveTimer)
|
|
||||||
}
|
|
||||||
mailStore.resetComposerState()
|
|
||||||
editor.value?.destroy()
|
editor.value?.destroy()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
const handleClose = () => {
|
const handleClose = async () => {
|
||||||
mailStore.resetComposerState()
|
await compositionStore.closeDraft()
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
if (!canSend.value || sending.value) return
|
await compositionStore.sendDraft()
|
||||||
|
|
||||||
try {
|
|
||||||
await mailStore.sendComposerMessage({
|
|
||||||
to: to.value,
|
|
||||||
cc: cc.value,
|
|
||||||
bcc: bcc.value,
|
|
||||||
subject: subject.value,
|
|
||||||
body: {
|
|
||||||
html: editor.value?.getHTML() || '',
|
|
||||||
text: editor.value?.getText() || '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Mail][Composer] Failed to send message:', error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAttach = async (files: File[]) => {
|
||||||
|
await compositionStore.addAttachments(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDetach = async (identifier: string) => {
|
||||||
|
await compositionStore.removeAttachment(identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleCc = () => {
|
const toggleCc = () => {
|
||||||
@@ -257,11 +195,6 @@ const setLink = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const removeLink = () => editor.value?.chain().focus().unsetLink().run()
|
const removeLink = () => editor.value?.chain().focus().unsetLink().run()
|
||||||
|
|
||||||
const isActive = (name: string, attrs?: any) => {
|
|
||||||
return editor.value?.isActive(name, attrs) || false
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleLink = () => {
|
const toggleLink = () => {
|
||||||
if (isActive('link')) {
|
if (isActive('link')) {
|
||||||
removeLink()
|
removeLink()
|
||||||
@@ -270,13 +203,16 @@ const toggleLink = () => {
|
|||||||
|
|
||||||
setLink()
|
setLink()
|
||||||
}
|
}
|
||||||
|
const isActive = (name: string, attrs?: any) => {
|
||||||
|
return editor.value?.isActive(name, attrs) || false
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="message-composer">
|
<div class="message-composer">
|
||||||
<ComposerToolbar
|
<ComposerToolbar
|
||||||
:mode="mode"
|
:mode="mode"
|
||||||
:save-status="saveStatus"
|
:status="stageStatus"
|
||||||
:can-send="canSend"
|
:can-send="canSend"
|
||||||
:sending="sending"
|
:sending="sending"
|
||||||
@close="handleClose"
|
@close="handleClose"
|
||||||
@@ -284,6 +220,11 @@ const toggleLink = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="composer-content">
|
<div class="composer-content">
|
||||||
|
<ComposerSender
|
||||||
|
v-model="sender"
|
||||||
|
:options="compositionStore.senderIdentities"
|
||||||
|
/>
|
||||||
|
|
||||||
<ComposerRecipients
|
<ComposerRecipients
|
||||||
:to="to"
|
:to="to"
|
||||||
:cc="cc"
|
:cc="cc"
|
||||||
@@ -301,6 +242,14 @@ const toggleLink = () => {
|
|||||||
|
|
||||||
<v-divider />
|
<v-divider />
|
||||||
|
|
||||||
|
<ComposerAttachments
|
||||||
|
v-if="Object.keys(attachments).length > 0"
|
||||||
|
:attachments="attachments"
|
||||||
|
@remove="handleDetach"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-divider v-if="Object.keys(attachments).length > 0" />
|
||||||
|
|
||||||
<ComposerEditor
|
<ComposerEditor
|
||||||
:editor="editor"
|
:editor="editor"
|
||||||
:is-bold-active="isActive('bold')"
|
:is-bold-active="isActive('bold')"
|
||||||
@@ -315,6 +264,7 @@ const toggleLink = () => {
|
|||||||
@bullet-list="toggleBulletList"
|
@bullet-list="toggleBulletList"
|
||||||
@ordered-list="toggleOrderedList"
|
@ordered-list="toggleOrderedList"
|
||||||
@link="toggleLink"
|
@link="toggleLink"
|
||||||
|
@attach="handleAttach"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -339,4 +289,5 @@ const toggleLink = () => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ const handleCancel = () => {
|
|||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="text-caption text-medium-emphasis">Account</div>
|
<div class="text-caption text-medium-emphasis">Account</div>
|
||||||
<div class="text-body-2">
|
<div class="text-body-2">
|
||||||
{{ service.label || service.primaryAddress || 'Mail Account' }}
|
{{ service.label || service.primaryAddress?.format() || 'Mail Account' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ComposerDraftAttachment } from '@/types/composer'
|
||||||
|
import { formatFileSize } from '@/utile/format'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
attachments: Record<string, ComposerDraftAttachment>
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
remove: [identifier: string]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="composer-attachments px-4 py-3">
|
||||||
|
<div class="text-caption text-medium-emphasis mb-2">Attachments</div>
|
||||||
|
<div class="composer-attachments-list">
|
||||||
|
<v-chip
|
||||||
|
v-for="attachment in Object.values(attachments)"
|
||||||
|
:key="attachment.identifier"
|
||||||
|
size="small"
|
||||||
|
closable
|
||||||
|
class="mr-2 mb-2"
|
||||||
|
@click:close="$emit('remove', attachment.identifier)"
|
||||||
|
>
|
||||||
|
{{ attachment.name }} ({{ formatFileSize(attachment.size) }})
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.composer-attachments {
|
||||||
|
border-bottom: 1px solid rgb(var(--v-border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-attachments-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
import type { PropType } from 'vue'
|
import type { PropType } from 'vue'
|
||||||
import { EditorContent, type Editor } from '@tiptap/vue-3'
|
import { EditorContent, type Editor } from '@tiptap/vue-3'
|
||||||
|
|
||||||
@@ -33,14 +34,32 @@ defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
bold: []
|
bold: []
|
||||||
italic: []
|
italic: []
|
||||||
underline: []
|
underline: []
|
||||||
bulletList: []
|
bulletList: []
|
||||||
orderedList: []
|
orderedList: []
|
||||||
link: []
|
link: []
|
||||||
|
attach: [files: File[]]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
function openFilePicker() {
|
||||||
|
fileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileChange(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement | null
|
||||||
|
const files = input?.files ? Array.from(input.files) : []
|
||||||
|
if (files.length > 0) {
|
||||||
|
emit('attach', files)
|
||||||
|
}
|
||||||
|
if (input) {
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -111,7 +130,15 @@ defineEmits<{
|
|||||||
|
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
|
|
||||||
<v-btn icon size="small">
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
class="sr-only"
|
||||||
|
@change="handleFileChange"
|
||||||
|
>
|
||||||
|
|
||||||
|
<v-btn icon size="small" @click="openFilePicker">
|
||||||
<v-icon>mdi-paperclip</v-icon>
|
<v-icon>mdi-paperclip</v-icon>
|
||||||
<v-tooltip activator="parent" location="bottom">Attach Files</v-tooltip>
|
<v-tooltip activator="parent" location="bottom">Attach Files</v-tooltip>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
@@ -120,7 +147,7 @@ defineEmits<{
|
|||||||
<v-divider />
|
<v-divider />
|
||||||
|
|
||||||
<div class="editor-container">
|
<div class="editor-container">
|
||||||
<EditorContent :editor="editor" />
|
<EditorContent :editor="editor ?? undefined" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -136,6 +163,10 @@ defineEmits<{
|
|||||||
background-color: rgb(var(--v-theme-background));
|
background-color: rgb(var(--v-theme-background));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.v-btn--active {
|
.v-btn--active {
|
||||||
background-color: rgba(var(--v-theme-primary), 0.12);
|
background-color: rgba(var(--v-theme-primary), 0.12);
|
||||||
color: rgb(var(--v-theme-primary));
|
color: rgb(var(--v-theme-primary));
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ComposerSenderIdentity } from '@/types/composer';
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: ComposerSenderIdentity | null
|
||||||
|
options: ComposerSenderIdentity[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: ComposerSenderIdentity | null]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
type SenderListItem = {
|
||||||
|
title: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = computed<SenderListItem[]>(() => {
|
||||||
|
return props.options.map(option => ({
|
||||||
|
title: formatSenderLabel(option.label, option.address),
|
||||||
|
value: option.address,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedValue = computed(() => props.modelValue?.address ?? null)
|
||||||
|
|
||||||
|
const singleOptionLabel = computed(() => {
|
||||||
|
const onlyOption = props.options[0]
|
||||||
|
return onlyOption ? formatSenderLabel(onlyOption.label, onlyOption.address) : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const errorMessages = computed(() => {
|
||||||
|
if (props.options.length === 0) {
|
||||||
|
return ['No send-capable account is available.']
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleUpdate(value: string | null) {
|
||||||
|
const match = value ? props.options.find(o => o.address === value) ?? null : null
|
||||||
|
emit('update:modelValue', match)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSenderLabel(label: string | null | undefined, address: string): string {
|
||||||
|
return label ? `${label} <${address}>` : address
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="composer-sender px-4 pt-4 pb-0">
|
||||||
|
<v-text-field
|
||||||
|
v-if="options.length === 1"
|
||||||
|
:model-value="singleOptionLabel"
|
||||||
|
label="From"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
readonly
|
||||||
|
class="mb-2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-select
|
||||||
|
v-else
|
||||||
|
:model-value="selectedValue"
|
||||||
|
:items="items"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
label="From"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
:error="errorMessages.length > 0"
|
||||||
|
:error-messages="errorMessages"
|
||||||
|
:disabled="options.length === 0"
|
||||||
|
class="mb-2"
|
||||||
|
@update:model-value="handleUpdate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.composer-sender {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,7 +3,7 @@ import { ComposerMode } from '@/types/composer'
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mode: ComposerMode
|
mode: ComposerMode
|
||||||
saveStatus: string
|
status: string
|
||||||
canSend: boolean
|
canSend: boolean
|
||||||
sending: boolean
|
sending: boolean
|
||||||
}
|
}
|
||||||
@@ -33,8 +33,8 @@ defineEmits<{
|
|||||||
|
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
|
|
||||||
<span v-if="saveStatus" class="text-caption text-medium-emphasis mr-4">
|
<span v-if="status" class="text-caption text-medium-emphasis mr-4">
|
||||||
{{ saveStatus }}
|
{{ status }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import RecipientDetails from '@/components/common/RecipientDetails.vue'
|
import RecipientDetails from '@/components/common/RecipientDetails.vue'
|
||||||
|
import { formatFileSize } from '@/utile/format'
|
||||||
import type { EntityObject } from '@MailManager/models';
|
import type { EntityObject } from '@MailManager/models';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -37,15 +38,6 @@ const formatDate = (date: Date | string | null | undefined): string => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format file size for display
|
|
||||||
const formatFileSize = (bytes: number | undefined): string => {
|
|
||||||
if (!bytes) return ''
|
|
||||||
|
|
||||||
if (bytes < 1024) return bytes + ' B'
|
|
||||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
|
||||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
|
||||||
}
|
|
||||||
|
|
||||||
const download = async (index: number): Promise<void> => {
|
const download = async (index: number): Promise<void> => {
|
||||||
emit('downloadAttachment', index)
|
emit('downloadAttachment', index)
|
||||||
}
|
}
|
||||||
@@ -120,8 +112,8 @@ const download = async (index: number): Promise<void> => {
|
|||||||
@click="download(index)"
|
@click="download(index)"
|
||||||
>
|
>
|
||||||
<span class="attachment-name">{{ attachment.name || 'Untitled' }}</span>
|
<span class="attachment-name">{{ attachment.name || 'Untitled' }}</span>
|
||||||
<span v-if="attachment.size" class="text-caption text-medium-emphasis ml-1">
|
<span v-if="attachment.size != null" class="text-caption text-medium-emphasis ml-1">
|
||||||
({{ formatFileSize(attachment.size ?? undefined) }})
|
({{ formatFileSize(attachment.size) }})
|
||||||
</span>
|
</span>
|
||||||
</v-chip>
|
</v-chip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const handleAccountSaved = async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<v-list-item-title>{{ service.label || 'Unnamed Account' }}</v-list-item-title>
|
<v-list-item-title>{{ service.label || 'Unnamed Account' }}</v-list-item-title>
|
||||||
<v-list-item-subtitle>{{ service.primaryAddress || service.identifier }}</v-list-item-subtitle>
|
<v-list-item-subtitle>{{ service.primaryAddress?.address || service.identifier }}</v-list-item-subtitle>
|
||||||
|
|
||||||
<template #append>
|
<template #append>
|
||||||
<v-btn
|
<v-btn
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { createFetchWrapper } from '@KTXC'
|
||||||
|
import type { ApiRequest, ApiResponse } from '@MailManager/types/common'
|
||||||
|
import type {
|
||||||
|
CompositionAttachmentAddRequest,
|
||||||
|
CompositionAttachmentAddResponse,
|
||||||
|
CompositionAttachmentRemoveRequest,
|
||||||
|
CompositionAttachmentRemoveResponse,
|
||||||
|
CompositionDiscardRequest,
|
||||||
|
CompositionDiscardResponse,
|
||||||
|
CompositionPatchRequest,
|
||||||
|
CompositionPatchResponse,
|
||||||
|
CompositionSendRequest,
|
||||||
|
CompositionSendResponse,
|
||||||
|
CompositionStageRequest,
|
||||||
|
CompositionStageResponse,
|
||||||
|
} from '@/types/composition'
|
||||||
|
|
||||||
|
const fetchWrapper = createFetchWrapper()
|
||||||
|
const API_URL = '/m/mail/compose/v1'
|
||||||
|
const API_VERSION = 1
|
||||||
|
|
||||||
|
function generateTransactionId(): string {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post<TRequest, TResponse>(operation: string, data: TRequest): Promise<TResponse> {
|
||||||
|
const request: ApiRequest<TRequest> = {
|
||||||
|
version: API_VERSION,
|
||||||
|
transaction: generateTransactionId(),
|
||||||
|
operation,
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ApiResponse<TResponse> = await fetchWrapper.post(API_URL, request)
|
||||||
|
|
||||||
|
if (response.status === 'error') {
|
||||||
|
const errorMessage = `[${operation}] ${response.data.message}${response.data.code ? ` (code: ${response.data.code})` : ''}`
|
||||||
|
throw new Error(errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
const compositionService = {
|
||||||
|
async stage(request: CompositionStageRequest): Promise<CompositionStageResponse> {
|
||||||
|
return await post<CompositionStageRequest, CompositionStageResponse>('stage', request)
|
||||||
|
},
|
||||||
|
|
||||||
|
async patch(request: CompositionPatchRequest): Promise<CompositionPatchResponse> {
|
||||||
|
return await post<CompositionPatchRequest, CompositionPatchResponse>('patch', request)
|
||||||
|
},
|
||||||
|
|
||||||
|
async discard(request: CompositionDiscardRequest): Promise<CompositionDiscardResponse> {
|
||||||
|
return await post<CompositionDiscardRequest, CompositionDiscardResponse>('discard', request)
|
||||||
|
},
|
||||||
|
|
||||||
|
async send(request: CompositionSendRequest): Promise<CompositionSendResponse> {
|
||||||
|
return await post<CompositionSendRequest, CompositionSendResponse>('send', request)
|
||||||
|
},
|
||||||
|
|
||||||
|
async attachmentAdd(request: CompositionAttachmentAddRequest): Promise<CompositionAttachmentAddResponse> {
|
||||||
|
return await post<CompositionAttachmentAddRequest, CompositionAttachmentAddResponse>('attachment.add', request)
|
||||||
|
},
|
||||||
|
|
||||||
|
async attachmentRemove(request: CompositionAttachmentRemoveRequest): Promise<CompositionAttachmentRemoveResponse> {
|
||||||
|
return await post<CompositionAttachmentRemoveRequest, CompositionAttachmentRemoveResponse>('attachment.remove', request)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default compositionService
|
||||||
@@ -0,0 +1,552 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useServicesStore } from '@MailManager/stores/servicesStore'
|
||||||
|
import { useMailUiStore } from './mailUiStore'
|
||||||
|
import compositionService from '@/services/compositionService'
|
||||||
|
import type {
|
||||||
|
CompositionAttachmentAddRequest,
|
||||||
|
CompositionAttachmentInterface,
|
||||||
|
CompositionAttachmentRemoveRequest,
|
||||||
|
CompositionPatchRequest,
|
||||||
|
CompositionStageRequest,
|
||||||
|
CompositionStageResponse,
|
||||||
|
} from '@/types/composition'
|
||||||
|
import { ComposerMode } from '@/types/composer'
|
||||||
|
import type { ComposerDraft, ComposerDraftAttachment, ComposerDraftMessage, ComposerSenderIdentity } from '@/types/composer'
|
||||||
|
import { EntityObject, MessageAddressObject, ServiceObject } from '@MailManager/models'
|
||||||
|
import type { ServiceIdentifier } from '@MailManager/services'
|
||||||
|
|
||||||
|
export const useMailCompositionStore = defineStore('mailCompositionStore', () => {
|
||||||
|
const servicesStore = useServicesStore()
|
||||||
|
const mailUiStore = useMailUiStore()
|
||||||
|
const activeDraft = ref<ComposerDraft | null>(null)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const stageStatus = computed(() => {
|
||||||
|
if (!activeDraft.value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saving.value) {
|
||||||
|
return 'Saving...'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeDraft.value.stageStatus === 'local') {
|
||||||
|
return 'Local draft only'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeDraft.value.stagedAt) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const seconds = Math.floor((Date.now() - activeDraft.value.stagedAt.getTime()) / 1000)
|
||||||
|
if (seconds < 60) {
|
||||||
|
return 'Staged just now'
|
||||||
|
}
|
||||||
|
if (seconds < 3600) {
|
||||||
|
return `Staged ${Math.floor(seconds / 60)} min ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Staged at ${activeDraft.value.stagedAt.toLocaleTimeString()}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const senderIdentities = computed(() => {
|
||||||
|
const identities: ComposerSenderIdentity[] = []
|
||||||
|
|
||||||
|
servicesStore.servicesEnabled.forEach(service => {
|
||||||
|
if (!service.capable('EntityTransmit') || service.identifier === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (service.primaryAddress && !service.primaryAddress.empty) {
|
||||||
|
identities.push({
|
||||||
|
service: service,
|
||||||
|
address: service.primaryAddress.address,
|
||||||
|
label: service.primaryAddress.label ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
service.secondaryAddresses.forEach(addr => {
|
||||||
|
identities.push({
|
||||||
|
service: service,
|
||||||
|
address: addr.address,
|
||||||
|
label: addr.label,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return identities
|
||||||
|
})
|
||||||
|
|
||||||
|
function openDraft(mode: ComposerMode, source?: EntityObject | MessageAddressObject | null) {
|
||||||
|
|
||||||
|
let sender = null
|
||||||
|
|
||||||
|
// find sending identity from source message recipients if possible
|
||||||
|
if (source instanceof EntityObject) {
|
||||||
|
const sourceMessage = source.properties
|
||||||
|
const recipients = [...(sourceMessage.to || []), ...(sourceMessage.cc || []), ...(sourceMessage.bcc || [])]
|
||||||
|
|
||||||
|
const matchingRecipient = recipients.find(recipient => {
|
||||||
|
return senderIdentities.value.some(identity => identity.address === recipient.address)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (matchingRecipient) {
|
||||||
|
sender = senderIdentities.value.find(identity => identity.address === matchingRecipient.address) ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// find sending identity from currently selected folder service if possible
|
||||||
|
if (sender === null) {
|
||||||
|
const serviceId = mailUiStore.selectedFolder?.service
|
||||||
|
sender = senderIdentities.value.find(identity =>
|
||||||
|
serviceId === `${identity.service.provider}:${identity.service.identifier}`
|
||||||
|
) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback to first available identity
|
||||||
|
if (sender === null) {
|
||||||
|
sender = senderIdentities.value[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sender === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activeDraft.value = buildDraft(mode, sender, source)
|
||||||
|
activeDraft.value.action = mode
|
||||||
|
|
||||||
|
stageDraft()
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDraft() {
|
||||||
|
return discardDraft()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stageDraft() {
|
||||||
|
if (!activeDraft.value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const request: CompositionStageRequest = {
|
||||||
|
identifier: activeDraft.value.identifier,
|
||||||
|
action: activeDraft.value.action,
|
||||||
|
sender: {
|
||||||
|
provider: activeDraft.value.sender.service.provider,
|
||||||
|
service: activeDraft.value.sender.service.identifier,
|
||||||
|
address: activeDraft.value.sender.address,
|
||||||
|
name: activeDraft.value.sender.label || null,
|
||||||
|
},
|
||||||
|
source: activeDraft.value.source,
|
||||||
|
message: activeDraft.value.message,
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await compositionService.stage(request)
|
||||||
|
applyStageResponse(response)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Mail][Composer] Failed to stage draft:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchDraft() {
|
||||||
|
if (!activeDraft.value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const request: CompositionPatchRequest = {
|
||||||
|
identifier: activeDraft.value.identifier,
|
||||||
|
revision: activeDraft.value.revision,
|
||||||
|
sender: {
|
||||||
|
provider: activeDraft.value.sender.service.provider,
|
||||||
|
service: activeDraft.value.sender.service.identifier,
|
||||||
|
address: activeDraft.value.sender.address,
|
||||||
|
name: activeDraft.value.sender.label || null,
|
||||||
|
},
|
||||||
|
message: activeDraft.value.message,
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await compositionService.patch(request)
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Mail][Composer] Failed to patch draft:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discardDraft() {
|
||||||
|
if (!activeDraft.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const draftId = activeDraft.value.identifier
|
||||||
|
try {
|
||||||
|
await compositionService.discard({ identifier: draftId })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Mail][Composer] Failed to discard staged draft:', error)
|
||||||
|
} finally {
|
||||||
|
activeDraft.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendDraft() {
|
||||||
|
if (!activeDraft.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await compositionService.send({
|
||||||
|
identifier: activeDraft.value.identifier,
|
||||||
|
revision: activeDraft.value.revision,
|
||||||
|
sender: {
|
||||||
|
provider: activeDraft.value.sender.service.provider,
|
||||||
|
service: activeDraft.value.sender.service.identifier,
|
||||||
|
address: activeDraft.value.sender.address,
|
||||||
|
name: activeDraft.value.sender.label || null,
|
||||||
|
},
|
||||||
|
message: activeDraft.value.message,
|
||||||
|
attachments: activeDraft.value.attachments,
|
||||||
|
})
|
||||||
|
activeDraft.value = null
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Mail][Composer] Failed to send draft:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRecipients(field: 'to' | 'cc' | 'bcc', values: string[]) {
|
||||||
|
if (!activeDraft.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activeDraft.value.message[field] = [...values]
|
||||||
|
queueSave(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSubject(subject: string) {
|
||||||
|
if (!activeDraft.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activeDraft.value.message.subject = subject
|
||||||
|
queueSave(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBody(body: ComposerDraftMessage['body']) {
|
||||||
|
if (!activeDraft.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activeDraft.value.message.body = {
|
||||||
|
html: body.html,
|
||||||
|
text: body.text,
|
||||||
|
}
|
||||||
|
queueSave(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSender(sender: ComposerSenderIdentity) {
|
||||||
|
if (!activeDraft.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activeDraft.value.sender = sender
|
||||||
|
queueSave(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addAttachments(files: File[]) {
|
||||||
|
if (!activeDraft.value || files.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const composition = activeDraft.value.identifier
|
||||||
|
const attachments = Object.fromEntries(
|
||||||
|
await Promise.all(files.map(async file => {
|
||||||
|
const identifier = createIdentifier()
|
||||||
|
const data = await fileToBase64(file)
|
||||||
|
|
||||||
|
return [identifier, {
|
||||||
|
identifier,
|
||||||
|
composition,
|
||||||
|
origin: 'upload',
|
||||||
|
name: file.name,
|
||||||
|
type: file.type || 'application/octet-stream',
|
||||||
|
size: file.size,
|
||||||
|
source: null,
|
||||||
|
data,
|
||||||
|
} satisfies CompositionAttachmentInterface]
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const request: CompositionAttachmentAddRequest = {
|
||||||
|
composition,
|
||||||
|
attachments,
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await compositionService.attachmentAdd(request)
|
||||||
|
|
||||||
|
if (!activeDraft.value || response.composition !== activeDraft.value.identifier) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.disposition === 'error') {
|
||||||
|
console.error('[Mail][Composer] Failed to add attachments:', response.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.values(response.attachments).forEach(attachment => {
|
||||||
|
activeDraft.value!.attachments[attachment.identifier] = {
|
||||||
|
identifier: attachment.identifier,
|
||||||
|
composition: attachment.composition,
|
||||||
|
origin: attachment.origin,
|
||||||
|
name: attachment.name,
|
||||||
|
type: attachment.type,
|
||||||
|
size: attachment.size,
|
||||||
|
source: attachment.source,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAttachment(identifier: string) {
|
||||||
|
if (!activeDraft.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachment = activeDraft.value.attachments[identifier]
|
||||||
|
if (!attachment) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: CompositionAttachmentRemoveRequest = {
|
||||||
|
composition: attachment.composition,
|
||||||
|
identifier: attachment.identifier,
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await compositionService.attachmentRemove(request)
|
||||||
|
|
||||||
|
if (response.disposition === 'error') {
|
||||||
|
console.error('[Mail][Composer] Failed to remove attachment:', response.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete activeDraft.value.attachments[attachment.identifier]
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueSave(immediate: boolean) {
|
||||||
|
if (!activeDraft.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeDraft.value.revision += 1
|
||||||
|
|
||||||
|
if (autoSaveTimer) {
|
||||||
|
clearTimeout(autoSaveTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
autoSaveTimer = setTimeout(() => {
|
||||||
|
void flushSave()
|
||||||
|
}, immediate ? 0 : 15000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushSave() {
|
||||||
|
if (autoSaveTimer) {
|
||||||
|
clearTimeout(autoSaveTimer)
|
||||||
|
autoSaveTimer = null
|
||||||
|
}
|
||||||
|
if (!activeDraft.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await patchDraft()
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyStageResponse(response: CompositionStageResponse) {
|
||||||
|
const draft = activeDraft.value
|
||||||
|
if (!draft) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.identifier !== draft.identifier) {
|
||||||
|
console.warn('[Mail][Composer] Stage response identifier mismatch:', response.identifier, draft.identifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.revision < draft.revision) {
|
||||||
|
console.warn('[Mail][Composer] Stage response revision is older than current draft:', response.revision, draft.revision)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
draft.attachments = {}
|
||||||
|
Object.values(response.attachments).forEach(attachment => {
|
||||||
|
draft.attachments[attachment.identifier] = {
|
||||||
|
identifier: attachment.identifier,
|
||||||
|
composition: attachment.composition,
|
||||||
|
origin: attachment.origin,
|
||||||
|
name: attachment.name,
|
||||||
|
type: attachment.type,
|
||||||
|
size: attachment.size,
|
||||||
|
source: attachment.source,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
draft.revision = response.revision
|
||||||
|
draft.stageStatus = response.disposition
|
||||||
|
draft.stagedAt = new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeDraft,
|
||||||
|
saving,
|
||||||
|
stageStatus,
|
||||||
|
senderIdentities,
|
||||||
|
openDraft,
|
||||||
|
closeDraft,
|
||||||
|
sendDraft,
|
||||||
|
updateSender,
|
||||||
|
updateRecipients,
|
||||||
|
updateSubject,
|
||||||
|
updateBody,
|
||||||
|
addAttachments,
|
||||||
|
removeAttachment,
|
||||||
|
flushSave,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function buildDraft(
|
||||||
|
mode: ComposerMode,
|
||||||
|
sender: ComposerSenderIdentity,
|
||||||
|
source?: EntityObject | MessageAddressObject | null,
|
||||||
|
): ComposerDraft {
|
||||||
|
const composition = createIdentifier()
|
||||||
|
const freshMessage = buildMessage(mode, source)
|
||||||
|
const freshAttachments = buildAttachments(composition, mode, source)
|
||||||
|
const sourceIdentifier = source instanceof EntityObject ? source.identifier : null
|
||||||
|
return {
|
||||||
|
action: mode,
|
||||||
|
identifier: composition,
|
||||||
|
revision: 1,
|
||||||
|
sender: sender,
|
||||||
|
source: sourceIdentifier,
|
||||||
|
stageStatus: 'local',
|
||||||
|
stagedAt: null,
|
||||||
|
message: freshMessage,
|
||||||
|
attachments: freshAttachments,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMessage(
|
||||||
|
mode: ComposerMode,
|
||||||
|
source: EntityObject | MessageAddressObject | null | undefined,
|
||||||
|
): ComposerDraftMessage {
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
return emptyMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === ComposerMode.Fresh) {
|
||||||
|
const freshMessage = emptyMessage()
|
||||||
|
|
||||||
|
if (source && source instanceof MessageAddressObject) {
|
||||||
|
freshMessage.to = [source.address]
|
||||||
|
}
|
||||||
|
|
||||||
|
return freshMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(source instanceof EntityObject)) {
|
||||||
|
return emptyMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceMessage = source.properties
|
||||||
|
const originalSubject = sourceMessage.subject || ''
|
||||||
|
const originalBody = sourceMessage.getHtmlContent() || sourceMessage.getTextContent() || ''
|
||||||
|
const senderName = sourceMessage.from?.label || sourceMessage.from?.address || 'Unknown'
|
||||||
|
const sentAt = sourceMessage.sent || source.created || ''
|
||||||
|
const sentLabel = sentAt ? new Date(sentAt).toLocaleString() : 'an unknown time'
|
||||||
|
|
||||||
|
if (mode === ComposerMode.Reply) {
|
||||||
|
const fromEmail = sourceMessage.replyTo?.[0]?.address || sourceMessage.from?.address || ''
|
||||||
|
return {
|
||||||
|
to: fromEmail ? [fromEmail] : [],
|
||||||
|
cc: [],
|
||||||
|
bcc: [],
|
||||||
|
subject: /^Re:/i.test(originalSubject) ? originalSubject : `Re: ${originalSubject}`,
|
||||||
|
body: {
|
||||||
|
html: `<p><br></p><p>---------- Original message ---------</p><p>From: ${senderName}</p><p>Date: ${sentLabel}</p><p>Subject: ${originalSubject}</p><blockquote>${originalBody}</blockquote>`,
|
||||||
|
text: '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
to: [],
|
||||||
|
cc: [],
|
||||||
|
bcc: [],
|
||||||
|
subject: /^Fwd:/i.test(originalSubject) ? originalSubject : `Fwd: ${originalSubject}`,
|
||||||
|
body: {
|
||||||
|
html: `<p><br></p><p>---------- Forwarded message ---------</p><p>From: ${senderName}</p><p>Date: ${sentLabel}</p><p>Subject: ${originalSubject}</p><blockquote>${originalBody}</blockquote>`,
|
||||||
|
text: '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAttachments(composition: string, mode: ComposerMode, source?: EntityObject | MessageAddressObject | null): Record<string, ComposerDraftAttachment> {
|
||||||
|
if (mode === ComposerMode.Fresh || mode === ComposerMode.Reply || !(source instanceof EntityObject)) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceAttachments = source.properties.attachments || []
|
||||||
|
const attachments: Record<string, ComposerDraftAttachment> = {}
|
||||||
|
sourceAttachments.forEach(attachment => {
|
||||||
|
const id = createIdentifier()
|
||||||
|
attachments[id] = {
|
||||||
|
identifier: id,
|
||||||
|
composition: composition,
|
||||||
|
origin: 'source',
|
||||||
|
name: attachment.name || '',
|
||||||
|
type: attachment.type || 'application/octet-stream',
|
||||||
|
size: attachment.size || 0,
|
||||||
|
source: source.identifier,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return attachments
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyMessage(): ComposerDraftMessage {
|
||||||
|
return {
|
||||||
|
to: [],
|
||||||
|
cc: [],
|
||||||
|
bcc: [],
|
||||||
|
subject: '',
|
||||||
|
body: {
|
||||||
|
html: '',
|
||||||
|
text: '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIdentifier(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID()
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileToBase64(file: File): Promise<string> {
|
||||||
|
const buffer = await file.arrayBuffer()
|
||||||
|
let binary = ''
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
for (let index = 0; index < bytes.byteLength; index += 1) {
|
||||||
|
binary += String.fromCharCode(bytes[index])
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
+45
-3
@@ -1,5 +1,47 @@
|
|||||||
|
import type { ServiceObject } from "@MailManager/models/service"
|
||||||
|
import type { EntityIdentifier, ServiceIdentifier } from "@MailManager/types/common"
|
||||||
|
|
||||||
export enum ComposerMode {
|
export enum ComposerMode {
|
||||||
Fresh,
|
Fresh = 'fresh',
|
||||||
Reply,
|
Reply = 'reply',
|
||||||
Forward,
|
Forward = 'forward',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComposerDraftAttachment {
|
||||||
|
identifier: string
|
||||||
|
composition: string
|
||||||
|
origin: 'source' | 'upload'
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
size: number
|
||||||
|
source: EntityIdentifier | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComposerDraftMessage {
|
||||||
|
to: string[]
|
||||||
|
cc: string[]
|
||||||
|
bcc: string[]
|
||||||
|
subject: string
|
||||||
|
body: {
|
||||||
|
html: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComposerDraft {
|
||||||
|
action: ComposerMode
|
||||||
|
identifier: string
|
||||||
|
revision: number
|
||||||
|
sender: ComposerSenderIdentity
|
||||||
|
source: EntityIdentifier | null
|
||||||
|
stageStatus: 'local' | 'staged'
|
||||||
|
stagedAt: Date | null
|
||||||
|
message: ComposerDraftMessage
|
||||||
|
attachments: Record<string, ComposerDraftAttachment>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComposerSenderIdentity {
|
||||||
|
service: ServiceObject
|
||||||
|
address: string
|
||||||
|
label: string | null
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type { EntityIdentifier, ServiceIdentifier } from '@MailManager/types/common'
|
||||||
|
|
||||||
|
export interface CompositionSenderInterface {
|
||||||
|
provider: string
|
||||||
|
service: string | number | null
|
||||||
|
address: string
|
||||||
|
name: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompositionMessageInterface {
|
||||||
|
to: string[]
|
||||||
|
cc: string[]
|
||||||
|
bcc: string[]
|
||||||
|
subject: string
|
||||||
|
body: {
|
||||||
|
text: string
|
||||||
|
html: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompositionAttachmentInterface {
|
||||||
|
identifier: string
|
||||||
|
composition: string
|
||||||
|
origin: 'source' | 'upload'
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
size: number
|
||||||
|
source: EntityIdentifier | null
|
||||||
|
data?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompositionStageRequest {
|
||||||
|
identifier: string
|
||||||
|
action: 'fresh' | 'reply' | 'forward'
|
||||||
|
sender: CompositionSenderInterface
|
||||||
|
source?: EntityIdentifier | null
|
||||||
|
message: CompositionMessageInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompositionStageResponse {
|
||||||
|
identifier: string
|
||||||
|
revision: number
|
||||||
|
disposition: 'staged'
|
||||||
|
attachments: Record<string, CompositionAttachmentInterface>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompositionPatchRequest {
|
||||||
|
identifier: string
|
||||||
|
revision: number
|
||||||
|
sender: CompositionSenderInterface
|
||||||
|
message: CompositionMessageInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompositionPatchResponse {
|
||||||
|
identifier: string
|
||||||
|
revision: number
|
||||||
|
message: CompositionMessageInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompositionDiscardRequest {
|
||||||
|
identifier: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompositionDiscardResponse {
|
||||||
|
identifier: string
|
||||||
|
disposition: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompositionSendRequest {
|
||||||
|
identifier: string
|
||||||
|
revision: number
|
||||||
|
sender: CompositionSenderInterface
|
||||||
|
message: CompositionMessageInterface
|
||||||
|
attachments: Record<string, CompositionAttachmentInterface>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompositionSendResponse {
|
||||||
|
identifier: string
|
||||||
|
disposition: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompositionAttachmentAddRequest {
|
||||||
|
composition: string
|
||||||
|
attachments: Record<string, CompositionAttachmentInterface>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompositionAttachmentAddResponse {
|
||||||
|
disposition: 'added' | 'error'
|
||||||
|
error?: {
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
composition: string
|
||||||
|
attachments: Record<string, CompositionAttachmentInterface>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompositionAttachmentRemoveRequest {
|
||||||
|
composition: string
|
||||||
|
identifier: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompositionAttachmentRemoveResponse {
|
||||||
|
disposition: 'removed' | 'error'
|
||||||
|
error?: {
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
composition: string
|
||||||
|
identifier: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export function formatFileSize(bytes: number | null | undefined): string {
|
||||||
|
if (bytes == null || !Number.isFinite(bytes)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes < 1024) {
|
||||||
|
return `${Math.max(0, Math.round(bytes))} B`
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = ['KB', 'MB', 'GB', 'TB']
|
||||||
|
let value = bytes / 1024
|
||||||
|
let unitIndex = 0
|
||||||
|
|
||||||
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
value /= 1024
|
||||||
|
unitIndex += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const precision = value >= 10 || unitIndex === 0 ? 1 : 2
|
||||||
|
return `${value.toFixed(precision)} ${units[unitIndex]}`
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
"include": [
|
"include": [
|
||||||
"src/**/*",
|
"src/**/*",
|
||||||
"src/**/*.vue",
|
"src/**/*.vue",
|
||||||
|
"src/utile/**/*.ts",
|
||||||
"../../core/src/**/*.ts",
|
"../../core/src/**/*.ts",
|
||||||
"../mail_manager/src/**/*.ts",
|
"../mail_manager/src/**/*.ts",
|
||||||
"../mail_manager/src/**/*.vue"
|
"../mail_manager/src/**/*.vue"
|
||||||
|
|||||||
Reference in New Issue
Block a user