1 Commits

Author SHA1 Message Date
316e0fddbc chore(deps): update dependency phpunit/phpunit to v11.5.55
Some checks failed
JS Unit Tests / test (pull_request) Failing after 41s
Build Test / test (pull_request) Successful in 43s
PHP Unit Tests / test (pull_request) Successful in 1m28s
2026-05-21 03:38:26 +00:00
8 changed files with 1329 additions and 1054 deletions

View File

@@ -13,7 +13,6 @@ use InvalidArgumentException;
use KTXC\Http\Response\JsonResponse;
use KTXC\Http\Response\Response;
use KTXC\Http\Response\StreamedNdJsonResponse;
use KTXC\Http\Response\StreamedResponse;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
use KTXF\Controller\ControllerAbstract;
@@ -32,6 +31,12 @@ use KTXM\MailManager\Manager;
use Psr\Log\LoggerInterface;
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 {
private const ERR_MISSING_PROVIDER = 'Missing parameter: provider';
@@ -163,7 +168,6 @@ class DefaultController extends ControllerAbstract {
'entity.move' => $this->entityMove($tenantId, $userId, $data),
'entity.copy' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
'entity.transmit' => $this->entityTransmit($tenantId, $userId, $data),
'entity.download' => $this->entityDownload($tenantId, $userId, $data),
default => throw new InvalidArgumentException(self::ERR_INVALID_OPERATION . $operation)
};
@@ -554,14 +558,14 @@ class DefaultController extends ControllerAbstract {
if (is_bool($result)) {
return [
'disposition' => 'deleted'
'outcome' => 'deleted'
];
}
if ($result instanceof JsonSerializable) {
return [
'disposition' => 'moved',
'mutation' => $result
'outcome' => 'moved',
'data' => $result
];
}
@@ -769,21 +773,21 @@ class DefaultController extends ControllerAbstract {
}
private function entityDelete(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['targets'])) {
throw new InvalidArgumentException(self::ERR_MISSING_TARGETS);
if (!isset($data['sources'])) {
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
}
if (!is_array($data['targets'])) {
throw new InvalidArgumentException(self::ERR_INVALID_TARGETS);
if (!is_array($data['sources'])) {
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
}
$targets = ResourceIdentifiers::fromArray($data['targets']);
foreach ($targets as $target) {
if (!$target instanceof EntityIdentifier) {
throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service:collection:entity identifiers');
$sources = ResourceIdentifiers::fromArray($data['sources']);
foreach ($sources as $source) {
if (!$source instanceof EntityIdentifier) {
throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service:collection:entity identifiers');
}
}
return $this->mailManager->entityDelete($tenantId, $userId, ...$targets->all());
return $this->mailManager->entityDelete($tenantId, $userId, ...$sources->all());
}
private function entityPatch(string $tenantId, string $userId, array $data): mixed {
@@ -864,46 +868,4 @@ class DefaultController extends ControllerAbstract {
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,7 +20,6 @@ use KTXF\Mail\Service\ServiceCollectionMutableInterface;
use KTXF\Mail\Service\ServiceConfigurableInterface;
use KTXF\Mail\Service\ServiceEntityMutableInterface;
use KTXF\Mail\Service\ServiceMutableInterface;
use KTXF\Resource\BinaryResource;
use KTXF\Resource\Filter\IFilter;
use KTXF\Resource\Identifier\CollectionIdentifier;
use KTXF\Resource\Identifier\EntityIdentifier;
@@ -952,13 +951,6 @@ 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
*
@@ -1302,4 +1294,5 @@ class Manager {
return $operationOutcome;
}
}

2123
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,9 +18,9 @@
"test:coverage": "vitest run --coverage --config tests/js/vitest.config.ts"
},
"dependencies": {
"pinia": "^3.0.0",
"pinia": "^2.3.1",
"vue": "^3.5.18",
"vue-router": "^5.0.0",
"vue-router": "^4.5.1",
"vuetify": "^4.0.0"
},
"devDependencies": {
@@ -28,7 +28,7 @@
"@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "^4.0.18",
"@vue/tsconfig": "^0.9.0",
"typescript": "~6.0.0",
"typescript": "~5.8.3",
"vite": "^8.0.0",
"vue-tsc": "^3.0.5"
}

View File

@@ -2,7 +2,7 @@
* Entity management service
*/
import { transceivePost, transceiveStream, transceiveDownload } from './transceive';
import { transceivePost, transceiveStream } from './transceive';
import type {
EntityFetchRequest,
EntityFetchResponse,
@@ -27,7 +27,6 @@ import type {
EntityListBulkRequest,
EntityPatchResponse,
EntityPatchRequest,
EntityDownloadRequest,
} from '../types/entity';
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
import { EntityObject } from '../models';
@@ -111,7 +110,7 @@ export const entityService = {
// Convert response to EntityObject instances
const list: Record<string, EntityObject> = {};
Object.entries(response).forEach(([, entity]) => {
Object.entries(response).forEach(([identifier, entity]) => {
list[entity.identifier] = createEntityObject(entity);
});
@@ -217,16 +216,6 @@ export const entityService = {
async transmit(request: EntityTransmitRequest): Promise<EntityTransmitResponse> {
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;

View File

@@ -141,84 +141,3 @@ export async function transceiveStream<TRequest, TData>(
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,8 +7,7 @@ import { defineStore } from 'pinia'
import { entityService } from '../services'
import { EntityObject, MessageObject } from '../models'
import type {
EntityBlobSelector,
EntityDownloadRequest,
EntityListStreamRequest,
EntityTransmitRequest,
EntityTransmitResponse,
} from '../types/entity'
@@ -19,7 +18,7 @@ import type {
ListRange,
ListSort,
} from '../types/common'
import type { MessageInterface, MessagePartInterface } from '@/types/message'
import type { MessageInterface } from '@/types/message'
export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
// State
@@ -185,15 +184,15 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
const response = await entityService.delta({ sources })
// Process delta and update store
Object.entries(response).forEach(([, providerData]) => {
Object.entries(response).forEach(([provider, providerData]) => {
// Skip if no changes for provider
if (providerData === false) return
Object.entries(providerData).forEach(([, serviceData]) => {
Object.entries(providerData).forEach(([service, serviceData]) => {
// Skip if no changes for service
if (serviceData === false) return
Object.entries(serviceData).forEach(([, collectionData]) => {
Object.entries(serviceData).forEach(([collection, collectionData]) => {
// Skip if no changes for collection
if (collectionData === false) return
@@ -475,39 +474,6 @@ 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 {
// State (readonly)
@@ -529,6 +495,5 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
delta,
move,
transmit,
download,
}
})

View File

@@ -205,32 +205,4 @@ export interface EntityTransmitRequest {
export interface EntityTransmitResponse {
id: string;
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> {}
}