4 Commits

Author SHA1 Message Date
6e79ae9d86 chore(deps): update vitest monorepo to v4.1.8
Some checks failed
Build Test / test (pull_request) Successful in 37s
JS Unit Tests / test (pull_request) Failing after 30s
PHP Unit Tests / test (pull_request) Successful in 1m10s
2026-06-07 03:04:33 +00:00
0e4bd905a2 Merge pull request 'feat: mail entity download' (#37) from feat/mail-entity-download into main
All checks were successful
Renovate / renovate (push) Successful in 2m56s
Reviewed-on: #37
2026-05-29 03:22:55 +00:00
935743963f feat: mail entity download
Some checks failed
Build Test / test (pull_request) Successful in 32s
JS Unit Tests / test (pull_request) Failing after 31s
PHP Unit Tests / test (pull_request) Successful in 1m1s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-05-28 23:22:02 -04:00
502b18962c Merge pull request 'fix(deps): update dependency vue-router to v5' (#32) from renovate/vue-router-5.x into main
Some checks failed
Renovate / renovate (push) Failing after 1m38s
Reviewed-on: #32
2026-05-21 03:45:36 +00:00
7 changed files with 282 additions and 82 deletions

View File

@@ -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',
]);
}
} }

View File

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

110
package-lock.json generated
View File

@@ -592,14 +592,14 @@
} }
}, },
"node_modules/@vitest/coverage-v8": { "node_modules/@vitest/coverage-v8": {
"version": "4.1.7", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz",
"integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@bcoe/v8-coverage": "^1.0.2", "@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.7", "@vitest/utils": "4.1.8",
"ast-v8-to-istanbul": "^1.0.0", "ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2", "istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1", "istanbul-lib-report": "^3.0.1",
@@ -613,8 +613,8 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
}, },
"peerDependencies": { "peerDependencies": {
"@vitest/browser": "4.1.7", "@vitest/browser": "4.1.8",
"vitest": "4.1.7" "vitest": "4.1.8"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@vitest/browser": { "@vitest/browser": {
@@ -623,17 +623,17 @@
} }
}, },
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "4.1.7", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
"integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.1.0", "@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/spy": "4.1.7", "@vitest/spy": "4.1.8",
"@vitest/utils": "4.1.7", "@vitest/utils": "4.1.8",
"chai": "^6.2.2", "chai": "^6.2.2",
"tinyrainbow": "^3.1.0" "tinyrainbow": "^3.1.0"
}, },
@@ -642,14 +642,14 @@
} }
}, },
"node_modules/@vitest/mocker": { "node_modules/@vitest/mocker": {
"version": "4.1.7", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
"integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@vitest/spy": "4.1.7", "@vitest/spy": "4.1.8",
"estree-walker": "^3.0.3", "estree-walker": "^3.0.3",
"magic-string": "^0.30.21" "magic-string": "^0.30.21"
}, },
@@ -681,9 +681,9 @@
} }
}, },
"node_modules/@vitest/pretty-format": { "node_modules/@vitest/pretty-format": {
"version": "4.1.7", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
"integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -694,14 +694,14 @@
} }
}, },
"node_modules/@vitest/runner": { "node_modules/@vitest/runner": {
"version": "4.1.7", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
"integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@vitest/utils": "4.1.7", "@vitest/utils": "4.1.8",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
"funding": { "funding": {
@@ -709,15 +709,15 @@
} }
}, },
"node_modules/@vitest/snapshot": { "node_modules/@vitest/snapshot": {
"version": "4.1.7", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
"integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@vitest/pretty-format": "4.1.7", "@vitest/pretty-format": "4.1.8",
"@vitest/utils": "4.1.7", "@vitest/utils": "4.1.8",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
@@ -726,9 +726,9 @@
} }
}, },
"node_modules/@vitest/spy": { "node_modules/@vitest/spy": {
"version": "4.1.7", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
"integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
@@ -737,13 +737,13 @@
} }
}, },
"node_modules/@vitest/ui": { "node_modules/@vitest/ui": {
"version": "4.1.7", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.7.tgz", "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.8.tgz",
"integrity": "sha512-TP6utB2yX6rsJNVRo2qAlsi48i1YwFTrLV2tnTtWqJaYX7m4lRCCLirZBjU6xC5m0RsPHr+L2+N+eIPhgEzFfw==", "integrity": "sha512-RUS2ZU2TsduVrI+9c12uTNaKrNUTsm6yFt3fueEUB9iKvyC2UP83F+sqIz00HQIah4UOL1TMoDAki8K0NjGvsA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/utils": "4.1.7", "@vitest/utils": "4.1.8",
"fflate": "^0.8.2", "fflate": "^0.8.2",
"flatted": "^3.4.2", "flatted": "^3.4.2",
"pathe": "^2.0.3", "pathe": "^2.0.3",
@@ -755,17 +755,17 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
}, },
"peerDependencies": { "peerDependencies": {
"vitest": "4.1.7" "vitest": "4.1.8"
} }
}, },
"node_modules/@vitest/utils": { "node_modules/@vitest/utils": {
"version": "4.1.7", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
"integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "4.1.7", "@vitest/pretty-format": "4.1.8",
"convert-source-map": "^2.0.0", "convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0" "tinyrainbow": "^3.1.0"
}, },
@@ -2255,20 +2255,20 @@
} }
}, },
"node_modules/vitest": { "node_modules/vitest": {
"version": "4.1.7", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
"integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@vitest/expect": "4.1.7", "@vitest/expect": "4.1.8",
"@vitest/mocker": "4.1.7", "@vitest/mocker": "4.1.8",
"@vitest/pretty-format": "4.1.7", "@vitest/pretty-format": "4.1.8",
"@vitest/runner": "4.1.7", "@vitest/runner": "4.1.8",
"@vitest/snapshot": "4.1.7", "@vitest/snapshot": "4.1.8",
"@vitest/spy": "4.1.7", "@vitest/spy": "4.1.8",
"@vitest/utils": "4.1.7", "@vitest/utils": "4.1.8",
"es-module-lexer": "^2.0.0", "es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0", "expect-type": "^1.3.0",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
@@ -2296,12 +2296,12 @@
"@edge-runtime/vm": "*", "@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.7", "@vitest/browser-playwright": "4.1.8",
"@vitest/browser-preview": "4.1.7", "@vitest/browser-preview": "4.1.8",
"@vitest/browser-webdriverio": "4.1.7", "@vitest/browser-webdriverio": "4.1.8",
"@vitest/coverage-istanbul": "4.1.7", "@vitest/coverage-istanbul": "4.1.8",
"@vitest/coverage-v8": "4.1.7", "@vitest/coverage-v8": "4.1.8",
"@vitest/ui": "4.1.7", "@vitest/ui": "4.1.8",
"happy-dom": "*", "happy-dom": "*",
"jsdom": "*", "jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0" "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"

View File

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

View File

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

View File

@@ -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,
} }
}) })

View File

@@ -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> {}