Compare commits
20 Commits
8f6d65911e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e4bd905a2 | |||
| 935743963f | |||
| 502b18962c | |||
| 138b5adcac | |||
| e2192be442 | |||
| e86de50f2e | |||
| 4b0ffa95cb | |||
| bc389ed4c0 | |||
| da696cd104 | |||
| 111decce15 | |||
| adabcd7c9a | |||
| 751ec564ae | |||
| 9b46bb81a1 | |||
| 2e14440b95 | |||
| aaec58c7e6 | |||
| f56cc681a6 | |||
| ef94d6dede | |||
| b4e379bccd | |||
| b67d1498e3 | |||
| 3567fa6f20 |
18
composer.lock
generated
18
composer.lock
generated
@@ -592,16 +592,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpunit/phpunit",
|
"name": "phpunit/phpunit",
|
||||||
"version": "11.5.53",
|
"version": "11.5.55",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||||
"reference": "a997a653a82845f1240d73ee73a8a4e97e4b0607"
|
"reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a997a653a82845f1240d73ee73a8a4e97e4b0607",
|
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00",
|
||||||
"reference": "a997a653a82845f1240d73ee73a8a4e97e4b0607",
|
"reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -674,7 +674,7 @@
|
|||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||||
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
||||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.53"
|
"source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -698,7 +698,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-02-10T12:28:25+00:00"
|
"time": "2026-02-18T12:37:06+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sebastian/cli-parser",
|
"name": "sebastian/cli-parser",
|
||||||
@@ -1791,15 +1791,15 @@
|
|||||||
],
|
],
|
||||||
"aliases": [],
|
"aliases": [],
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"stability-flags": [],
|
"stability-flags": {},
|
||||||
"prefer-stable": false,
|
"prefer-stable": false,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": ">=8.2 <=8.5"
|
"php": ">=8.2 <=8.5"
|
||||||
},
|
},
|
||||||
"platform-dev": [],
|
"platform-dev": {},
|
||||||
"platform-overrides": {
|
"platform-overrides": {
|
||||||
"php": "8.2"
|
"php": "8.2"
|
||||||
},
|
},
|
||||||
"plugin-api-version": "2.3.0"
|
"plugin-api-version": "2.9.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use InvalidArgumentException;
|
|||||||
use KTXC\Http\Response\JsonResponse;
|
use KTXC\Http\Response\JsonResponse;
|
||||||
use KTXC\Http\Response\Response;
|
use KTXC\Http\Response\Response;
|
||||||
use KTXC\Http\Response\StreamedNdJsonResponse;
|
use KTXC\Http\Response\StreamedNdJsonResponse;
|
||||||
|
use KTXC\Http\Response\StreamedResponse;
|
||||||
use KTXC\SessionIdentity;
|
use KTXC\SessionIdentity;
|
||||||
use KTXC\SessionTenant;
|
use KTXC\SessionTenant;
|
||||||
use KTXF\Controller\ControllerAbstract;
|
use KTXF\Controller\ControllerAbstract;
|
||||||
@@ -31,12 +32,6 @@ use KTXM\MailManager\Manager;
|
|||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
|
||||||
* Default Controller - Unified Mail API
|
|
||||||
*
|
|
||||||
* Handles all mail operations in JMAP-style API pattern.
|
|
||||||
* Supports both single operations and batches with result references.
|
|
||||||
*/
|
|
||||||
class DefaultController extends ControllerAbstract {
|
class DefaultController extends ControllerAbstract {
|
||||||
|
|
||||||
private const ERR_MISSING_PROVIDER = 'Missing parameter: provider';
|
private const ERR_MISSING_PROVIDER = 'Missing parameter: provider';
|
||||||
@@ -168,6 +163,7 @@ class DefaultController extends ControllerAbstract {
|
|||||||
'entity.move' => $this->entityMove($tenantId, $userId, $data),
|
'entity.move' => $this->entityMove($tenantId, $userId, $data),
|
||||||
'entity.copy' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
|
'entity.copy' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
|
||||||
'entity.transmit' => $this->entityTransmit($tenantId, $userId, $data),
|
'entity.transmit' => $this->entityTransmit($tenantId, $userId, $data),
|
||||||
|
'entity.download' => $this->entityDownload($tenantId, $userId, $data),
|
||||||
|
|
||||||
default => throw new InvalidArgumentException(self::ERR_INVALID_OPERATION . $operation)
|
default => throw new InvalidArgumentException(self::ERR_INVALID_OPERATION . $operation)
|
||||||
};
|
};
|
||||||
@@ -558,14 +554,14 @@ class DefaultController extends ControllerAbstract {
|
|||||||
|
|
||||||
if (is_bool($result)) {
|
if (is_bool($result)) {
|
||||||
return [
|
return [
|
||||||
'outcome' => 'deleted'
|
'disposition' => 'deleted'
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($result instanceof JsonSerializable) {
|
if ($result instanceof JsonSerializable) {
|
||||||
return [
|
return [
|
||||||
'outcome' => 'moved',
|
'disposition' => 'moved',
|
||||||
'data' => $result
|
'mutation' => $result
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,21 +769,21 @@ class DefaultController extends ControllerAbstract {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private function entityDelete(string $tenantId, string $userId, array $data): mixed {
|
private function entityDelete(string $tenantId, string $userId, array $data): mixed {
|
||||||
if (!isset($data['sources'])) {
|
if (!isset($data['targets'])) {
|
||||||
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
|
throw new InvalidArgumentException(self::ERR_MISSING_TARGETS);
|
||||||
}
|
}
|
||||||
if (!is_array($data['sources'])) {
|
if (!is_array($data['targets'])) {
|
||||||
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
|
throw new InvalidArgumentException(self::ERR_INVALID_TARGETS);
|
||||||
}
|
}
|
||||||
|
|
||||||
$sources = ResourceIdentifiers::fromArray($data['sources']);
|
$targets = ResourceIdentifiers::fromArray($data['targets']);
|
||||||
foreach ($sources as $source) {
|
foreach ($targets as $target) {
|
||||||
if (!$source instanceof EntityIdentifier) {
|
if (!$target instanceof EntityIdentifier) {
|
||||||
throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service:collection:entity identifiers');
|
throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service:collection:entity identifiers');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->mailManager->entityDelete($tenantId, $userId, ...$sources->all());
|
return $this->mailManager->entityDelete($tenantId, $userId, ...$targets->all());
|
||||||
}
|
}
|
||||||
|
|
||||||
private function entityPatch(string $tenantId, string $userId, array $data): mixed {
|
private function entityPatch(string $tenantId, string $userId, array $data): mixed {
|
||||||
@@ -868,4 +864,46 @@ class DefaultController extends ControllerAbstract {
|
|||||||
return ['jobId' => $jobId];
|
return ['jobId' => $jobId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function entityDownload(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
if (!isset($data['target'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_TARGET);
|
||||||
|
}
|
||||||
|
if (!is_string($data['target'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'part' is optional — null means full RFC 822 message download
|
||||||
|
$part = isset($data['part']) && is_array($data['part']) ? $data['part'] : null;
|
||||||
|
|
||||||
|
$target = ResourceIdentifier::fromString($data['target']);
|
||||||
|
$logger = $this->logger;
|
||||||
|
|
||||||
|
$result = $this->mailManager->entityDownload($tenantId, $userId, $target, $part);
|
||||||
|
|
||||||
|
$filename = $result->filename();
|
||||||
|
$asciiFilename = preg_replace('/[^\x20-\x7E]|[\\\\"]/', '_', $filename);
|
||||||
|
$encodedFilename = rawurlencode($filename);
|
||||||
|
$disposition = sprintf(
|
||||||
|
'attachment; filename="%s"; filename*=UTF-8\'\'%s',
|
||||||
|
$asciiFilename,
|
||||||
|
$encodedFilename,
|
||||||
|
);
|
||||||
|
|
||||||
|
$responseGenerator = (static function () use ($result, $logger): \Generator {
|
||||||
|
try {
|
||||||
|
yield from $result->stream();
|
||||||
|
} catch (\Throwable $t) {
|
||||||
|
$logger->error('Error streaming entity download', ['exception' => $t]);
|
||||||
|
// Headers already sent — cannot change status code; stop output cleanly
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return new StreamedResponse($responseGenerator, 200, [
|
||||||
|
'Content-Disposition' => $disposition,
|
||||||
|
'Content-Type' => $result->mimeType(),
|
||||||
|
'Content-Transfer-Encoding' => 'binary',
|
||||||
|
'Cache-Control' => 'no-store',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use KTXF\Mail\Service\ServiceCollectionMutableInterface;
|
|||||||
use KTXF\Mail\Service\ServiceConfigurableInterface;
|
use KTXF\Mail\Service\ServiceConfigurableInterface;
|
||||||
use KTXF\Mail\Service\ServiceEntityMutableInterface;
|
use KTXF\Mail\Service\ServiceEntityMutableInterface;
|
||||||
use KTXF\Mail\Service\ServiceMutableInterface;
|
use KTXF\Mail\Service\ServiceMutableInterface;
|
||||||
|
use KTXF\Resource\BinaryResource;
|
||||||
use KTXF\Resource\Filter\IFilter;
|
use KTXF\Resource\Filter\IFilter;
|
||||||
use KTXF\Resource\Identifier\CollectionIdentifier;
|
use KTXF\Resource\Identifier\CollectionIdentifier;
|
||||||
use KTXF\Resource\Identifier\EntityIdentifier;
|
use KTXF\Resource\Identifier\EntityIdentifier;
|
||||||
@@ -951,6 +952,13 @@ class Manager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function entityDownload(string $tenantId, string $userId, EntityIdentifier $targetEntity, array|null $targetPart): BinaryResource {
|
||||||
|
// retrieve service
|
||||||
|
$service = $this->serviceFetch($tenantId, $userId, $targetEntity->provider(), $targetEntity->service());
|
||||||
|
// download entity
|
||||||
|
return $service->entityDownload($targetEntity, $targetPart);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if messages exist
|
* Check if messages exist
|
||||||
*
|
*
|
||||||
@@ -1294,5 +1302,4 @@ class Manager {
|
|||||||
return $operationOutcome;
|
return $operationOutcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
2261
package-lock.json
generated
2261
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -18,18 +18,18 @@
|
|||||||
"test:coverage": "vitest run --coverage --config tests/js/vitest.config.ts"
|
"test:coverage": "vitest run --coverage --config tests/js/vitest.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pinia": "^2.3.1",
|
"pinia": "^3.0.0",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^5.0.0",
|
||||||
"vuetify": "^3.10.2"
|
"vuetify": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"@vitest/ui": "^4.0.18",
|
"@vitest/ui": "^4.0.18",
|
||||||
"@vue/tsconfig": "^0.7.0",
|
"@vue/tsconfig": "^0.9.0",
|
||||||
"typescript": "~6.0.0",
|
"typescript": "~6.0.0",
|
||||||
"vite": "^7.1.2",
|
"vite": "^8.0.0",
|
||||||
"vue-tsc": "^3.0.5"
|
"vue-tsc": "^3.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Entity management service
|
* Entity management service
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { transceivePost, transceiveStream } from './transceive';
|
import { transceivePost, transceiveStream, transceiveDownload } from './transceive';
|
||||||
import type {
|
import type {
|
||||||
EntityFetchRequest,
|
EntityFetchRequest,
|
||||||
EntityFetchResponse,
|
EntityFetchResponse,
|
||||||
@@ -27,6 +27,7 @@ import type {
|
|||||||
EntityListBulkRequest,
|
EntityListBulkRequest,
|
||||||
EntityPatchResponse,
|
EntityPatchResponse,
|
||||||
EntityPatchRequest,
|
EntityPatchRequest,
|
||||||
|
EntityDownloadRequest,
|
||||||
} from '../types/entity';
|
} from '../types/entity';
|
||||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
||||||
import { EntityObject } from '../models';
|
import { EntityObject } from '../models';
|
||||||
@@ -110,7 +111,7 @@ export const entityService = {
|
|||||||
|
|
||||||
// Convert response to EntityObject instances
|
// Convert response to EntityObject instances
|
||||||
const list: Record<string, EntityObject> = {};
|
const list: Record<string, EntityObject> = {};
|
||||||
Object.entries(response).forEach(([identifier, entity]) => {
|
Object.entries(response).forEach(([, entity]) => {
|
||||||
list[entity.identifier] = createEntityObject(entity);
|
list[entity.identifier] = createEntityObject(entity);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,6 +217,16 @@ export const entityService = {
|
|||||||
async transmit(request: EntityTransmitRequest): Promise<EntityTransmitResponse> {
|
async transmit(request: EntityTransmitRequest): Promise<EntityTransmitResponse> {
|
||||||
return await transceivePost<EntityTransmitRequest, EntityTransmitResponse>('entity.transmit', request);
|
return await transceivePost<EntityTransmitRequest, EntityTransmitResponse>('entity.transmit', request);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a browser-native attachment download request.
|
||||||
|
*
|
||||||
|
* The backend download endpoint is expected to honor the supplied selector
|
||||||
|
* and respond with an attachment payload rather than JSON.
|
||||||
|
*/
|
||||||
|
download(request: EntityDownloadRequest): { transaction: string } {
|
||||||
|
return transceiveDownload<EntityDownloadRequest>('entity.download', request);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default entityService;
|
export default entityService;
|
||||||
|
|||||||
@@ -141,3 +141,84 @@ export async function transceiveStream<TRequest, TData>(
|
|||||||
|
|
||||||
return { total };
|
return { total };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a browser-native file download request via a top-level form POST.
|
||||||
|
*
|
||||||
|
* This avoids buffering the response body in application-managed JavaScript
|
||||||
|
* memory. The backend is expected to accept form fields where `data` contains
|
||||||
|
* the serialized operation payload and to return an attachment response.
|
||||||
|
*/
|
||||||
|
export function transceiveDownload<TRequest>(
|
||||||
|
operation: string,
|
||||||
|
data: TRequest,
|
||||||
|
user?: string,
|
||||||
|
): { transaction: string } {
|
||||||
|
if (typeof document === 'undefined' || typeof window === 'undefined') {
|
||||||
|
throw new Error('Browser window is not available for download submission');
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: ApiRequest<TRequest> = {
|
||||||
|
version: API_VERSION,
|
||||||
|
transaction: generateTransactionId(),
|
||||||
|
operation,
|
||||||
|
data,
|
||||||
|
user,
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = API_URL;
|
||||||
|
form.target = '_blank';
|
||||||
|
form.style.display = 'none';
|
||||||
|
|
||||||
|
appendHiddenField(form, 'version', String(request.version));
|
||||||
|
appendHiddenField(form, 'transaction', request.transaction);
|
||||||
|
appendHiddenField(form, 'operation', request.operation);
|
||||||
|
appendFormValue(form, 'data', request.data);
|
||||||
|
|
||||||
|
if (request.user) {
|
||||||
|
appendHiddenField(form, 'user', request.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
form.remove();
|
||||||
|
|
||||||
|
return { transaction: request.transaction };
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendHiddenField(form: HTMLFormElement, name: string, value: string): void {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = name;
|
||||||
|
input.value = value;
|
||||||
|
form.appendChild(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendFormValue(form: HTMLFormElement, name: string, value: unknown): void {
|
||||||
|
if (value === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
appendHiddenField(form, name, '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((item, index) => {
|
||||||
|
appendFormValue(form, `${name}[${index}]`, item);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
Object.entries(value as Record<string, unknown>).forEach(([key, nestedValue]) => {
|
||||||
|
appendFormValue(form, `${name}[${key}]`, nestedValue);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
appendHiddenField(form, name, String(value));
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { defineStore } from 'pinia'
|
|||||||
import { entityService } from '../services'
|
import { entityService } from '../services'
|
||||||
import { EntityObject, MessageObject } from '../models'
|
import { EntityObject, MessageObject } from '../models'
|
||||||
import type {
|
import type {
|
||||||
EntityListStreamRequest,
|
EntityBlobSelector,
|
||||||
|
EntityDownloadRequest,
|
||||||
EntityTransmitRequest,
|
EntityTransmitRequest,
|
||||||
EntityTransmitResponse,
|
EntityTransmitResponse,
|
||||||
} from '../types/entity'
|
} from '../types/entity'
|
||||||
@@ -18,7 +19,7 @@ import type {
|
|||||||
ListRange,
|
ListRange,
|
||||||
ListSort,
|
ListSort,
|
||||||
} from '../types/common'
|
} from '../types/common'
|
||||||
import type { MessageInterface } from '@/types/message'
|
import type { MessageInterface, MessagePartInterface } from '@/types/message'
|
||||||
|
|
||||||
export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
||||||
// State
|
// State
|
||||||
@@ -184,15 +185,15 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
|||||||
const response = await entityService.delta({ sources })
|
const response = await entityService.delta({ sources })
|
||||||
|
|
||||||
// Process delta and update store
|
// Process delta and update store
|
||||||
Object.entries(response).forEach(([provider, providerData]) => {
|
Object.entries(response).forEach(([, providerData]) => {
|
||||||
// Skip if no changes for provider
|
// Skip if no changes for provider
|
||||||
if (providerData === false) return
|
if (providerData === false) return
|
||||||
|
|
||||||
Object.entries(providerData).forEach(([service, serviceData]) => {
|
Object.entries(providerData).forEach(([, serviceData]) => {
|
||||||
// Skip if no changes for service
|
// Skip if no changes for service
|
||||||
if (serviceData === false) return
|
if (serviceData === false) return
|
||||||
|
|
||||||
Object.entries(serviceData).forEach(([collection, collectionData]) => {
|
Object.entries(serviceData).forEach(([, collectionData]) => {
|
||||||
// Skip if no changes for collection
|
// Skip if no changes for collection
|
||||||
if (collectionData === false) return
|
if (collectionData === false) return
|
||||||
|
|
||||||
@@ -474,6 +475,39 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function download(target: EntityIdentifier, part?: Partial<MessagePartInterface>) {
|
||||||
|
let targetPart: EntityBlobSelector | undefined = undefined
|
||||||
|
if (part && (part.blobId || part.partId || part.cid)) {
|
||||||
|
targetPart = {
|
||||||
|
blobId: part.blobId ?? undefined,
|
||||||
|
partId: part.partId ?? undefined,
|
||||||
|
cid: part.cid ?? undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let filename: string
|
||||||
|
if (part && part.name && part.name.trim().length > 0) {
|
||||||
|
filename = part.name.trim()
|
||||||
|
} else if (part) {
|
||||||
|
filename = `attachment-${part.partId || part.blobId || part.cid || 'unknown'}`
|
||||||
|
} else {
|
||||||
|
filename = 'message.eml'
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: EntityDownloadRequest = {
|
||||||
|
target,
|
||||||
|
part: targetPart,
|
||||||
|
filename,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
entityService.download(request)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Mail Manager][Store] - Failed to submit attachment download:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Return public API
|
// Return public API
|
||||||
return {
|
return {
|
||||||
// State (readonly)
|
// State (readonly)
|
||||||
@@ -495,5 +529,6 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
|||||||
delta,
|
delta,
|
||||||
move,
|
move,
|
||||||
transmit,
|
transmit,
|
||||||
|
download,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -205,4 +205,32 @@ export interface EntityTransmitRequest {
|
|||||||
export interface EntityTransmitResponse {
|
export interface EntityTransmitResponse {
|
||||||
id: string;
|
id: string;
|
||||||
status: 'queued' | 'sent';
|
status: 'queued' | 'sent';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity Blob Fetch
|
||||||
|
*/
|
||||||
|
export interface EntityBlobSelector {
|
||||||
|
blobId?: string;
|
||||||
|
partId?: string;
|
||||||
|
cid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityDownloadRequest {
|
||||||
|
target: EntityIdentifier;
|
||||||
|
part?: EntityBlobSelector;
|
||||||
|
filename?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityBlobsRequest {
|
||||||
|
target: EntityIdentifier;
|
||||||
|
parts: EntityBlobSelector[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityBlobsResult {
|
||||||
|
source: EntityIdentifier;
|
||||||
|
part: EntityBlobSelector;
|
||||||
|
blob: Blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityBlobsResponse extends Array<EntityBlobsResult> {}
|
||||||
Reference in New Issue
Block a user