diff --git a/package-lock.json b/package-lock.json index e7db3a0..cf9af92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@internxt/css-config": "^1.1.0", "@internxt/lib": "^1.4.1", - "@internxt/sdk": "^1.17.4", + "@internxt/sdk": "^1.17.5", "@internxt/ui": "^0.1.16", "@phosphor-icons/react": "^2.1.10", "@reduxjs/toolkit": "^2.11.2", @@ -32,7 +32,7 @@ "dompurify": "^3.3.3", "i18next": "^25.8.13", "idb": "^8.0.3", - "internxt-crypto": "1.4.0", + "internxt-crypto": "^1.5.0", "prettysize": "^2.0.0", "react": "^19.2.0", "react-device-detect": "^2.2.3", @@ -1455,9 +1455,9 @@ "license": "MIT" }, "node_modules/@internxt/sdk": { - "version": "1.17.4", - "resolved": "https://registry.npmjs.org/@internxt/sdk/-/sdk-1.17.4.tgz", - "integrity": "sha512-sIbIbv6e/M4S7rzLv5O6Gs4dzB9q8/U6W4o3egbHSvodDVjc33hHrsjygtxoXVHDXR44pd2u3QMxNGPAcA7nEw==", + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/@internxt/sdk/-/sdk-1.17.5.tgz", + "integrity": "sha512-cck3ERfNRBf9wu1Zouv+Oyfl0tsYhw1YPi8vqMz1g8cRvTyJDP0nNEjUCCxW2aUIyzeTJGbl0MHBk5EwYHuuyw==", "license": "MIT", "dependencies": { "axios": "^1.16.0" @@ -7086,9 +7086,9 @@ "license": "ISC" }, "node_modules/internxt-crypto": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/internxt-crypto/-/internxt-crypto-1.4.0.tgz", - "integrity": "sha512-4G7M1CBhnkLBbIfMhXpCJA9mTezZfJnleTgRM7Z0iicCXdjlGzBfygRJwlnnhKNvh45pu9hIQ+M5dA0jVIIKzw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/internxt-crypto/-/internxt-crypto-1.5.0.tgz", + "integrity": "sha512-t/gjzVuer28IkNij3kf2f42CBo4AHzK+evcXyEp3qdSG1A/7Tex77ck7imwvsYH1Ngq6D/AIWvEt6LHX2ftm0w==", "dependencies": { "@noble/ciphers": "^2.2.0", "@noble/curves": "^2.2.0", diff --git a/package.json b/package.json index 7e8f72f..d022dfe 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "dependencies": { "@internxt/css-config": "^1.1.0", "@internxt/lib": "^1.4.1", - "@internxt/sdk": "^1.17.4", + "@internxt/sdk": "^1.17.5", "@internxt/ui": "^0.1.16", "@phosphor-icons/react": "^2.1.10", "@reduxjs/toolkit": "^2.11.2", @@ -45,7 +45,7 @@ "dompurify": "^3.3.3", "i18next": "^25.8.13", "idb": "^8.0.3", - "internxt-crypto": "1.4.0", + "internxt-crypto": "^1.5.0", "prettysize": "^2.0.0", "react": "^19.2.0", "react-device-detect": "^2.2.3", diff --git a/src/components/compose-message/hooks/useAttachments.test.ts b/src/components/compose-message/hooks/useAttachments.test.ts index 020874f..82f3eef 100644 --- a/src/components/compose-message/hooks/useAttachments.test.ts +++ b/src/components/compose-message/hooks/useAttachments.test.ts @@ -1,10 +1,9 @@ import { renderHook, act } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import useAttachments from './useAttachments'; -import { UploadManager } from '@/services/upload-manager'; import notificationsService, { ToastType } from '@/services/notifications'; -import type { UploadAttachmentCallbacks, UploadHandle } from '@/types/mail/upload-manager'; import { MAX_TOTAL_ATTACHMENT_BYTES_PER_MAIL } from '@/constants'; +import type { UploadManagerCallbacks } from '@/services/upload-manager'; vi.mock('@/i18n', () => ({ useTranslationContext: () => ({ translate: (key: string) => key }) })); @@ -13,54 +12,65 @@ vi.mock('@/services/notifications', () => ({ ToastType: { Success: 'success', Error: 'error', Warning: 'warning', Info: 'info', Loading: 'loading' }, })); +vi.mock('internxt-crypto', () => ({ genSymmetricKey: () => new Uint8Array(32) })); + +const enqueue = vi.fn(); +const retryFn = vi.fn(); +const cancel = vi.fn(); +const clear = vi.fn(); +let lastCallbacks: UploadManagerCallbacks | undefined; + vi.mock('@/services/upload-manager', () => ({ - UploadManager: { instance: { run: vi.fn(), retry: vi.fn(), remove: vi.fn(), clear: vi.fn() } }, + UploadManager: class { + constructor(_sessionKey: Uint8Array, callbacks: UploadManagerCallbacks) { + lastCallbacks = callbacks; + } + enqueue = enqueue; + retry = retryFn; + cancel = cancel; + clear = clear; + }, })); -const run = vi.mocked(UploadManager.instance.run); -const retry = vi.mocked(UploadManager.instance.retry); -const remove = vi.mocked(UploadManager.instance.remove); -const clear = vi.mocked(UploadManager.instance.clear); const show = vi.mocked(notificationsService.show); -let lastCallbacks: UploadAttachmentCallbacks | undefined; - +let idSeq = 0; const fileOfSize = (size: number, name = 'a.txt', type = 'text/plain'): File => { const f = new File(['x'], name, { type }); Object.defineProperty(f, 'size', { value: size }); return f; }; -describe('Attachments - custom hook', () => { - beforeEach(() => { - run.mockReset(); - retry.mockReset(); - remove.mockReset(); - clear.mockReset(); - show.mockReset(); - lastCallbacks = undefined; - run.mockImplementation((files: File[], callbacks: UploadAttachmentCallbacks): UploadHandle[] => { - lastCallbacks = callbacks; - return files.map((file, i) => ({ id: `id-${i}`, file })); - }); - }); +beforeEach(() => { + idSeq = 0; + vi.stubGlobal('crypto', { ...globalThis.crypto, randomUUID: () => `id-${idSeq++}` }); + enqueue.mockReset(); + retryFn.mockReset(); + cancel.mockReset(); + clear.mockReset(); + show.mockReset(); + lastCallbacks = undefined; +}); - afterEach(() => { - vi.restoreAllMocks(); - }); +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); +describe('Attachments - custom hook', () => { describe('Adding files', () => { test('When files are added, then files are handled as expected', () => { const f1 = fileOfSize(100, '1.txt'); const f2 = fileOfSize(200, '2.bin', 'application/octet-stream'); - const { result } = renderHook(() => useAttachments()); + const { result } = renderHook(() => useAttachments(new Uint8Array(32))); act(() => result.current.addFiles([f1, f2])); - expect(run).toHaveBeenCalledWith([f1, f2], expect.any(Object)); + expect(enqueue).toHaveBeenNthCalledWith(1, 'id-0', f1); + expect(enqueue).toHaveBeenNthCalledWith(2, 'id-1', f2); expect(result.current.attachments).toEqual([ - { id: 'id-0', name: '1.txt', size: 100, type: 'text/plain', status: 'uploading' }, - { id: 'id-1', name: '2.bin', size: 200, type: 'application/octet-stream', status: 'uploading' }, + { id: 'id-0', name: '1.txt', size: 100, type: 'text/plain', status: 'uploading', file: f1 }, + { id: 'id-1', name: '2.bin', size: 200, type: 'application/octet-stream', status: 'uploading', file: f2 }, ]); expect(result.current.totalSize).toBe(f1.size + f2.size); expect(result.current.isUploading).toBe(true); @@ -74,9 +84,9 @@ describe('Attachments - custom hook', () => { expect(result.current.totalSize).toBe(300); }); - test('When the new batch would exceed 25 MB total, then it shows a warning toast and does not enqueue', () => { + test('When the new batch would exceed the limit, then it shows a warning toast and does not enqueue', () => { const big = fileOfSize(MAX_TOTAL_ATTACHMENT_BYTES_PER_MAIL + 1); - const { result } = renderHook(() => useAttachments()); + const { result } = renderHook(() => useAttachments(new Uint8Array(32))); act(() => result.current.addFiles([big])); @@ -84,13 +94,13 @@ describe('Attachments - custom hook', () => { text: 'modals.composeMessageDialog.errors.attachmentsTooLarge', type: ToastType.Warning, }); - expect(run).not.toHaveBeenCalled(); + expect(enqueue).not.toHaveBeenCalled(); expect(result.current.attachments).toHaveLength(0); }); - test('When the cumulative size reaches the 25 MB limit, then a subsequent batch is rejected', () => { + test('When the cumulative size reaches the limit, then a subsequent batch is rejected', () => { const half = fileOfSize(MAX_TOTAL_ATTACHMENT_BYTES_PER_MAIL / 2); - const { result } = renderHook(() => useAttachments()); + const { result } = renderHook(() => useAttachments(new Uint8Array(32))); act(() => result.current.addFiles([half])); act(() => lastCallbacks?.onSuccess('id-0', 'blob-0')); @@ -102,8 +112,8 @@ describe('Attachments - custom hook', () => { }); describe('upload callbacks', () => { - test('When the manager reports success, then the attachment moves to done and the id of the blob is stored', () => { - const { result } = renderHook(() => useAttachments()); + test('When the manager reports success, then the attachment moves to done and the blobId is stored', () => { + const { result } = renderHook(() => useAttachments(new Uint8Array(32))); act(() => result.current.addFiles([fileOfSize(10)])); act(() => lastCallbacks?.onSuccess('id-0', 'blob-1')); @@ -114,7 +124,7 @@ describe('Attachments - custom hook', () => { }); test('When the manager reports an error, then the attachment moves to error', () => { - const { result } = renderHook(() => useAttachments()); + const { result } = renderHook(() => useAttachments(new Uint8Array(32))); act(() => result.current.addFiles([fileOfSize(10)])); act(() => lastCallbacks?.onError('id-0', new Error('boom'))); @@ -127,25 +137,25 @@ describe('Attachments - custom hook', () => { describe('retry', () => { test('When retry is called on a failed attachment, then it goes back to uploading and the manager is notified', () => { - const { result } = renderHook(() => useAttachments()); + const { result } = renderHook(() => useAttachments(new Uint8Array(32))); act(() => result.current.addFiles([fileOfSize(10)])); act(() => lastCallbacks?.onError('id-0', new Error('x'))); act(() => result.current.retry('id-0')); - expect(retry).toHaveBeenCalledWith('id-0', expect.any(Object)); + expect(retryFn).toHaveBeenCalledWith('id-0'); expect(result.current.attachments[0].status).toBe('uploading'); }); }); describe('remove', () => { test('When remove is called, then the attachment is dropped and the manager is notified', () => { - const { result } = renderHook(() => useAttachments()); + const { result } = renderHook(() => useAttachments(new Uint8Array(32))); act(() => result.current.addFiles([fileOfSize(10, '1.txt'), fileOfSize(20, '2.txt')])); act(() => result.current.remove('id-0')); - expect(remove).toHaveBeenCalledWith('id-0'); + expect(cancel).toHaveBeenCalledWith('id-0'); expect(result.current.attachments).toHaveLength(1); expect(result.current.attachments[0].id).toBe('id-1'); }); @@ -153,7 +163,7 @@ describe('Attachments - custom hook', () => { describe('clear', () => { test('When clear is called, then every attachment is removed from the manager and state', () => { - const { result } = renderHook(() => useAttachments()); + const { result } = renderHook(() => useAttachments(new Uint8Array(32))); act(() => result.current.addFiles([fileOfSize(10, '1.txt'), fileOfSize(20, '2.txt')])); act(() => result.current.clear()); diff --git a/src/components/compose-message/hooks/useAttachments.ts b/src/components/compose-message/hooks/useAttachments.ts index 9634435..2c6068b 100644 --- a/src/components/compose-message/hooks/useAttachments.ts +++ b/src/components/compose-message/hooks/useAttachments.ts @@ -1,12 +1,11 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import type { AttachmentRef } from '@internxt/sdk/dist/mail/types'; import { useTranslationContext } from '@/i18n'; import notificationsService, { ToastType } from '@/services/notifications'; import { bytesToString } from '@/utils/bytes-to-string'; -import { UploadManager } from '@/services/upload-manager'; -import type { AttachmentRef } from '@internxt/sdk/dist/mail/types'; import { MAX_TOTAL_ATTACHMENT_BYTES_PER_MAIL } from '@/constants'; -import type { UploadAttachmentCallbacks } from '@/types/mail/upload-manager'; import { ErrorService } from '@/services/error'; +import { UploadManager } from '@/services/upload-manager'; export type AttachmentStatus = 'uploading' | 'done' | 'error'; @@ -14,31 +13,29 @@ export interface AttachmentTask extends Omit { id: string; status: AttachmentStatus; blobId?: string; + file: File; } -const useAttachments = () => { +const useAttachments = (sessionKey: Uint8Array) => { const { translate } = useTranslationContext(); const [attachments, setAttachments] = useState([]); + const managerRef = useRef(null); + managerRef.current ??= new UploadManager(sessionKey, { + onSuccess: (id, blobId) => + setAttachments((prev) => prev.map((a) => (a.id === id ? { ...a, blobId, status: 'done' } : a))), + onError: (id, error) => { + console.error('ERROR UPLOADING ATTACHMENT', ErrorService.instance.castError(error)); + setAttachments((prev) => prev.map((a) => (a.id === id ? { ...a, status: 'error' } : a))); + }, + }); + + const manager = managerRef.current; + const totalSize = useMemo(() => attachments.reduce((s, a) => s + a.size, 0), [attachments]); const isUploading = useMemo(() => attachments.some((a) => a.status === 'uploading'), [attachments]); const hasErrors = useMemo(() => attachments.some((a) => a.status === 'error'), [attachments]); - const onTaskCompleted = (attachmentTaskId: AttachmentTask['id'], blobId: string) => { - setAttachments((prev) => prev.map((a) => (a.id === attachmentTaskId ? { ...a, blobId, status: 'done' } : a))); - }; - - const onTaskError = (attachmentTaskId: AttachmentTask['id'], error: unknown) => { - const castedError = ErrorService.instance.castError(error); - setAttachments((prev) => prev.map((a) => (a.id === attachmentTaskId ? { ...a, status: 'error' } : a))); - console.error('ERROR UPLOADING ATTACHMENT', castedError); - }; - - const callbacks: UploadAttachmentCallbacks = { - onSuccess: onTaskCompleted, - onError: onTaskError, - }; - const addFiles = useCallback( (files: FileList | File[]) => { const list = Array.from(files); @@ -52,38 +49,52 @@ const useAttachments = () => { }); return; } - const handles = UploadManager.instance.run(list, callbacks); - const pending: AttachmentTask[] = handles.map(({ id, file }) => ({ - id, + + const pending: AttachmentTask[] = list.map((file) => ({ + id: crypto.randomUUID(), name: file.name, size: file.size, type: file.type ?? 'application/octet-stream', status: 'uploading', + file, })); setAttachments((prev) => [...prev, ...pending]); + pending.forEach(({ id, file }) => manager.enqueue(id, file)); }, - [totalSize, translate, callbacks], + [totalSize, translate, manager], ); const retry = useCallback( (id: string) => { setAttachments((prev) => prev.map((a) => (a.id === id ? { ...a, status: 'uploading' } : a))); - UploadManager.instance.retry(id, callbacks); + manager.retry(id); }, - [callbacks], + [manager], ); - const remove = useCallback((id: string) => { - UploadManager.instance.remove(id); - setAttachments((prev) => prev.filter((a) => a.id !== id)); - }, []); + const remove = useCallback( + (id: string) => { + manager.cancel(id); + setAttachments((prev) => prev.filter((a) => a.id !== id)); + }, + [manager], + ); const clear = useCallback(() => { - UploadManager.instance.clear(); + manager.clear(); setAttachments([]); - }, []); + }, [manager]); - return { attachments, totalSize, isUploading, hasErrors, addFiles, retry, remove, clear }; + return { + attachments, + totalSize, + isUploading, + hasErrors, + addFiles, + retry, + remove, + clear, + }; }; export default useAttachments; diff --git a/src/components/compose-message/hooks/useComposeSend.test.ts b/src/components/compose-message/hooks/useComposeSend.test.ts index bcb61a6..d1ec140 100644 --- a/src/components/compose-message/hooks/useComposeSend.test.ts +++ b/src/components/compose-message/hooks/useComposeSend.test.ts @@ -1,7 +1,6 @@ import { renderHook, act } from '@testing-library/react'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import type { Editor } from '@tiptap/react'; -import type { EncryptionBlock } from '@internxt/sdk/dist/mail/types'; import useComposeSend from './useComposeSend'; import { MailEncryptionService } from '@/services/mail-encryption'; import notificationsService from '@/services/notifications'; @@ -42,6 +41,7 @@ const renderSend = (overrides: Partial[0]> = { subject: 'Hi', editor, attachments: [], + attachmentsSessionKey: new Uint8Array(32), onSent, ...overrides, }), @@ -178,7 +178,8 @@ describe('useComposeSend', () => { encryptedText: 'ct', encryptedPreview: 'cp', wrappedKeys: [], - } as EncryptionBlock); + attachmentWrappedKeys: [], + }); const { result, onSent } = renderSend({ toRecipients: [recipient('bob@inxt.me')] }); @@ -194,6 +195,7 @@ describe('useComposeSend', () => { { address: 'bob@inxt.me', publicKey: 'bob-pk' }, { address: 'me@inxt.me', publicKey: 'sender-pk' }, ]), + expect.any(Uint8Array), ); expect(mocks.sendEmail).toHaveBeenCalledWith( expect.objectContaining({ subject: 'Hi', encryption: expect.objectContaining({ version: 'v1' }) }), diff --git a/src/components/compose-message/hooks/useComposeSend.ts b/src/components/compose-message/hooks/useComposeSend.ts index d567c87..bf6325a 100644 --- a/src/components/compose-message/hooks/useComposeSend.ts +++ b/src/components/compose-message/hooks/useComposeSend.ts @@ -25,6 +25,7 @@ interface UseComposeSendParams { subject: string; editor: Editor | null; attachments: AttachmentTask[]; + attachmentsSessionKey: Uint8Array; onSent: () => void; } @@ -46,6 +47,7 @@ export const useComposeSend = ({ subject, editor, attachments, + attachmentsSessionKey, onSent, }: UseComposeSendParams): UseComposeSendResult => { const { translate } = useTranslationContext(); @@ -146,7 +148,9 @@ export const useComposeSend = ({ const encryption = await MailEncryptionService.instance.buildEncryptionBlock( { body: htmlBody || textBody, previewText: textBody }, recipientsWithKeys, + attachmentsSessionKey, ); + await sendEmail({ to: toRecipients.map(toEmailAddress), cc: ccRecipients.length ? ccRecipients.map(toEmailAddress) : undefined, @@ -175,6 +179,7 @@ export const useComposeSend = ({ encryptionState, senderKeys, attachments, + attachmentsSessionKey, triggerLookup, sendEmail, onSent, diff --git a/src/components/compose-message/index.tsx b/src/components/compose-message/index.tsx index e971d24..4d41c5a 100644 --- a/src/components/compose-message/index.tsx +++ b/src/components/compose-message/index.tsx @@ -1,5 +1,6 @@ import { LockKeyIcon, PaperclipIcon, WarningIcon, XIcon } from '@phosphor-icons/react'; -import { useCallback, useRef, type ChangeEvent } from 'react'; +import { useCallback, useRef, useState, type ChangeEvent } from 'react'; +import { genSymmetricKey } from 'internxt-crypto'; import type { Recipient } from './types'; import { RecipientInput } from './components/RecipientInput'; import { AttachmentList } from './components/AttachmentList'; @@ -49,6 +50,8 @@ export const ComposeMessageDialog = () => { const title = draft.subject ?? translate('modals.composeMessageDialog.title'); const editor = useEditor(EDITOR_CONFIG); + const [attachmentsSessionKey] = useState(() => genSymmetricKey()); + const { attachments, totalSize: attachmentsTotalSize, @@ -58,7 +61,7 @@ export const ComposeMessageDialog = () => { retry: retryAttachment, remove: removeAttachment, clear: clearAttachments, - } = useAttachments(); + } = useAttachments(attachmentsSessionKey); const fileInputRef = useRef(null); const onClose = useCallback(() => { @@ -70,6 +73,7 @@ export const ComposeMessageDialog = () => { const { send, encryptionState, isSending } = useComposeSend({ attachments, + attachmentsSessionKey, bccRecipients, ccRecipients, editor, diff --git a/src/features/mail/MailView.tsx b/src/features/mail/MailView.tsx index bdf0994..e230ecb 100644 --- a/src/features/mail/MailView.tsx +++ b/src/features/mail/MailView.tsx @@ -157,6 +157,7 @@ const MailView = ({ folder }: MailViewProps) => { isEncrypted: decrypted.isEncrypted, isDecrypting: decrypted.isDecrypting, decryptError: decrypted.decryptError, + envelope: decrypted.envelope, }} /> )} diff --git a/src/features/mail/components/mail-preview/index.tsx b/src/features/mail/components/mail-preview/index.tsx index d7101cc..c31f78c 100644 --- a/src/features/mail/components/mail-preview/index.tsx +++ b/src/features/mail/components/mail-preview/index.tsx @@ -2,7 +2,7 @@ import { LockKeyIcon, WarningIcon } from '@phosphor-icons/react'; import { useTranslationContext } from '@/i18n'; import PreviewHeader, { type User } from './header'; import Preview from './preview'; -import type { EmailResponse } from '@internxt/sdk/dist/mail/types'; +import type { EmailResponse, EncryptionBlock } from '@internxt/sdk/dist/mail/types'; interface PreviewMailProps { from: User; @@ -18,6 +18,7 @@ interface PreviewMailProps { isEncrypted?: boolean; isDecrypting?: boolean; decryptError?: boolean; + envelope?: EncryptionBlock | null; }; } @@ -49,7 +50,13 @@ const PreviewMail = ({ from, to, cc, bcc, mail }: PreviewMailProps) => { {mail.isDecrypting ? (
{translate('mail.preview.decrypting')}
) : ( - + )} ); diff --git a/src/features/mail/components/mail-preview/preview/index.tsx b/src/features/mail/components/mail-preview/preview/index.tsx index 2a42d13..304f27e 100644 --- a/src/features/mail/components/mail-preview/preview/index.tsx +++ b/src/features/mail/components/mail-preview/preview/index.tsx @@ -1,10 +1,11 @@ -import type { EmailResponse } from '@internxt/sdk/dist/mail/types'; +import type { EmailResponse, EncryptionBlock } from '@internxt/sdk/dist/mail/types'; import { DownloadSimpleIcon, PaperclipIcon } from '@phosphor-icons/react'; import DOMPurify from 'dompurify'; import { bytesToString } from '@/utils/bytes-to-string'; -import { MailService } from '@/services/sdk/mail'; import notificationsService, { ToastType } from '@/services/notifications'; import { useTranslationContext } from '@/i18n'; +import { useAttachmentsSessionKey } from '@/hooks/mail/useAttachmentsSessionKey'; +import { NetworkService } from '@/services/network'; const purify = DOMPurify(); @@ -37,25 +38,26 @@ interface PreviewProps { subject: string; body: string; attachments?: EmailResponse['attachments']; + envelope?: EncryptionBlock | null; } -const Preview = ({ mailId, subject, body, attachments }: PreviewProps) => { +const Preview = ({ mailId, subject, body, attachments, envelope }: PreviewProps) => { const { translate } = useTranslationContext(); const sanitizedBody = purify.sanitize(body); + const attachmentsSessionKey = useAttachmentsSessionKey(mailId, envelope ?? null); const onDownload = async (attachment: EmailAttachment) => { try { - const { data, contentType } = await MailService.instance.downloadAttachment( - mailId, - attachment.blobId, - attachment.name, - attachment.type, - ); - const blob = new Blob([data], { type: contentType || attachment.type }); + const { blob, name } = await NetworkService.instance.download({ + ...attachment, + mailId: mailId, + attachmentsSessionKey: attachmentsSessionKey!, + }); + const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; - link.download = attachment.name; + link.download = name ?? attachment.name; link.click(); setTimeout(() => URL.revokeObjectURL(url), 0); } catch { diff --git a/src/hooks/mail/useAttachmentsSessionKey.test.tsx b/src/hooks/mail/useAttachmentsSessionKey.test.tsx new file mode 100644 index 0000000..247bc86 --- /dev/null +++ b/src/hooks/mail/useAttachmentsSessionKey.test.tsx @@ -0,0 +1,121 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { EncryptionBlock } from '@internxt/sdk/dist/mail/types'; +import type { HybridKeyPair } from 'internxt-crypto'; +import { useAttachmentsSessionKey } from './useAttachmentsSessionKey'; +import { MailEncryptionService } from '@/services/mail-encryption'; + +vi.mock('./useMailKeys', () => ({ useMailKeys: vi.fn() })); + +const { useMailKeys } = await import('./useMailKeys'); +const useMailKeysMock = vi.mocked(useMailKeys); + +const KEY = new Uint8Array([1, 2, 3, 4]); +const KEYPAIR = { secretKey: new Uint8Array(32), publicKey: new Uint8Array(32) } as unknown as HybridKeyPair; +const ENVELOPE = { + version: 'v1', + encryptedText: 'ct', + encryptedPreview: 'cp', + wrappedKeys: [], + attachmentWrappedKeys: [], +} as unknown as EncryptionBlock; + +beforeEach(() => { + useMailKeysMock.mockReturnValue(KEYPAIR); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('useAttachmentsSessionKey', () => { + test('When mailId is null, then the hook returns null and skips decryption', async () => { + const decryptSpy = vi.spyOn(MailEncryptionService.instance, 'decryptAttachmentsSessionKey').mockResolvedValue(KEY); + + const { result } = renderHook(() => useAttachmentsSessionKey(null, ENVELOPE)); + + expect(result.current).toBeNull(); + expect(decryptSpy).not.toHaveBeenCalled(); + }); + + test('When envelope is null, then the hook returns null and skips decryption', async () => { + const decryptSpy = vi.spyOn(MailEncryptionService.instance, 'decryptAttachmentsSessionKey').mockResolvedValue(KEY); + + const { result } = renderHook(() => useAttachmentsSessionKey('mail-1', null)); + + expect(result.current).toBeNull(); + expect(decryptSpy).not.toHaveBeenCalled(); + }); + + test('When the keypair is not yet available, then decryption is deferred until it loads', async () => { + useMailKeysMock.mockReturnValue(null); + const decryptSpy = vi.spyOn(MailEncryptionService.instance, 'decryptAttachmentsSessionKey').mockResolvedValue(KEY); + + const { result, rerender } = renderHook(() => useAttachmentsSessionKey('mail-1', ENVELOPE)); + + expect(result.current).toBeNull(); + expect(decryptSpy).not.toHaveBeenCalled(); + + useMailKeysMock.mockReturnValue(KEYPAIR); + rerender(); + + await waitFor(() => expect(result.current).toBe(KEY)); + expect(decryptSpy).toHaveBeenCalledTimes(1); + }); + + test('When inputs are ready, then the decrypted session key is returned', async () => { + vi.spyOn(MailEncryptionService.instance, 'decryptAttachmentsSessionKey').mockResolvedValue(KEY); + + const { result } = renderHook(() => useAttachmentsSessionKey('mail-1', ENVELOPE)); + + await waitFor(() => expect(result.current).toBe(KEY)); + }); + + test('When the hook re-renders for the same mailId, then the key is decrypted only once', async () => { + const decryptSpy = vi.spyOn(MailEncryptionService.instance, 'decryptAttachmentsSessionKey').mockResolvedValue(KEY); + + const { result, rerender } = renderHook(() => useAttachmentsSessionKey('mail-1', ENVELOPE)); + + await waitFor(() => expect(result.current).toBe(KEY)); + rerender(); + rerender(); + + expect(decryptSpy).toHaveBeenCalledTimes(1); + }); + + test('When the mailId changes, then the new envelope is decrypted independently', async () => { + const otherKey = new Uint8Array([9, 9, 9]); + const decryptSpy = vi + .spyOn(MailEncryptionService.instance, 'decryptAttachmentsSessionKey') + .mockResolvedValueOnce(KEY) + .mockResolvedValueOnce(otherKey); + + let mailId = 'mail-1'; + const { result, rerender } = renderHook(() => useAttachmentsSessionKey(mailId, ENVELOPE)); + + await waitFor(() => expect(result.current).toBe(KEY)); + + act(() => { + mailId = 'mail-2'; + }); + rerender(); + + await waitFor(() => expect(result.current).toBe(otherKey)); + expect(decryptSpy).toHaveBeenCalledTimes(2); + }); + + test('When decryption fails, then the hook stays at null and logs the error once', async () => { + const error = new Error('decrypt failed'); + vi.spyOn(MailEncryptionService.instance, 'decryptAttachmentsSessionKey').mockRejectedValue(error); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result, rerender } = renderHook(() => useAttachmentsSessionKey('mail-1', ENVELOPE)); + + await waitFor(() => expect(consoleSpy).toHaveBeenCalledWith('Failed to decrypt attachments session key', error)); + expect(result.current).toBeNull(); + + consoleSpy.mockClear(); + rerender(); + expect(consoleSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/mail/useAttachmentsSessionKey.ts b/src/hooks/mail/useAttachmentsSessionKey.ts new file mode 100644 index 0000000..cdabc82 --- /dev/null +++ b/src/hooks/mail/useAttachmentsSessionKey.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react'; +import type { EncryptionBlock } from '@internxt/sdk/dist/mail/types'; +import { useMailKeys } from './useMailKeys'; +import { MailEncryptionService } from '@/services/mail-encryption'; + +type CachedKey = { ok: true; key: Uint8Array } | { ok: false }; + +/** + * Recovers and memoizes the symmetric session key used to decrypt every + * attachment in an encrypted email. The unwrap is done once per `mailId`; the + * cached result is reused across re-renders and across attachments within the + * same email. + */ +export const useAttachmentsSessionKey = ( + mailId: string | null, + envelope: EncryptionBlock | null, +): Uint8Array | null => { + const keypair = useMailKeys(); + const [cache, setCache] = useState>({}); + + useEffect(() => { + if (!mailId || !envelope || !keypair) return; + if (cache[mailId]) return; + + let cancelled = false; + MailEncryptionService.instance + .decryptAttachmentsSessionKey(envelope, keypair) + .then((key) => { + if (!cancelled) setCache((prev) => ({ ...prev, [mailId]: { ok: true, key } })); + }) + .catch((error) => { + console.error('Failed to decrypt attachments session key', error); + if (!cancelled) setCache((prev) => ({ ...prev, [mailId]: { ok: false } })); + }); + + return () => { + cancelled = true; + }; + }, [mailId, envelope, keypair, cache]); + + if (!mailId) return null; + const entry = cache[mailId]; + return entry?.ok ? entry.key : null; +}; diff --git a/src/hooks/mail/useDecryptedMail.test.tsx b/src/hooks/mail/useDecryptedMail.test.tsx index 39213a2..d07666d 100644 --- a/src/hooks/mail/useDecryptedMail.test.tsx +++ b/src/hooks/mail/useDecryptedMail.test.tsx @@ -49,6 +49,7 @@ describe('useDecryptedMail', () => { isEncrypted: false, isDecrypting: false, decryptError: false, + envelope: null, }); }); diff --git a/src/hooks/mail/useDecryptedMail.ts b/src/hooks/mail/useDecryptedMail.ts index 5cda6a4..ed94a2b 100644 --- a/src/hooks/mail/useDecryptedMail.ts +++ b/src/hooks/mail/useDecryptedMail.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import type { EmailResponse } from '@internxt/sdk/dist/mail/types'; +import type { EmailResponse, EncryptionBlock } from '@internxt/sdk/dist/mail/types'; import type { HybridKeyPair } from 'internxt-crypto'; import { useMailKeys } from './useMailKeys'; import { MailEncryptionService } from '@/services/mail-encryption'; @@ -10,6 +10,7 @@ type State = { isEncrypted: boolean; isDecrypting: boolean; decryptError: boolean; + envelope: EncryptionBlock | null; }; const EMPTY: State = { @@ -18,15 +19,16 @@ const EMPTY: State = { isEncrypted: false, isDecrypting: false, decryptError: false, + envelope: null, }; -type CachedResult = { ok: true; text: string } | { ok: false }; +type CachedResult = { ok: true; text: string; envelope: EncryptionBlock } | { ok: false }; const decryptMailBody = async (mail: EmailResponse, senderKeys: HybridKeyPair): Promise => { try { const envelope = MailEncryptionService.instance.parseEncryptionBlock(mail.textBody as string); const text = await MailEncryptionService.instance.decryptEnvelope(envelope, senderKeys); - return { ok: true, text }; + return { ok: true, text, envelope }; } catch (error) { console.error('Failed to decrypt mail body', error); return { ok: false }; @@ -65,17 +67,32 @@ export const useDecryptedMail = (mail: EmailResponse | undefined): State => { isEncrypted: false, isDecrypting: false, decryptError: false, + envelope: null, }; } const fresh = cached[mail.id] ?? null; if (!fresh) { - return { subject: mail.subject, htmlBody: '', isEncrypted: true, isDecrypting: true, decryptError: false }; + return { + subject: mail.subject, + htmlBody: '', + isEncrypted: true, + isDecrypting: true, + decryptError: false, + envelope: null, + }; } if (!fresh.ok) { - return { subject: mail.subject, htmlBody: '', isEncrypted: true, isDecrypting: false, decryptError: true }; + return { + subject: mail.subject, + htmlBody: '', + isEncrypted: true, + isDecrypting: false, + decryptError: true, + envelope: null, + }; } return { @@ -84,6 +101,7 @@ export const useDecryptedMail = (mail: EmailResponse | undefined): State => { isEncrypted: true, isDecrypting: false, decryptError: false, + envelope: fresh.envelope, }; }, [mail, isEncrypted, cached]); }; diff --git a/src/services/mail-encryption/index.test.ts b/src/services/mail-encryption/index.test.ts index 5a2f0cb..926280d 100644 --- a/src/services/mail-encryption/index.test.ts +++ b/src/services/mail-encryption/index.test.ts @@ -1,9 +1,10 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { generateEmailKeys, uint8ArrayToBase64 } from 'internxt-crypto'; +import { generateEmailKeys, genSymmetricKey, uint8ArrayToBase64 } from 'internxt-crypto'; import { ENCRYPTED_EMAIL_PREFIX, MailEncryptionService, type RecipientPublicKey } from '.'; const mailEncryption = MailEncryptionService.instance; const content = (body: string, previewText = body) => ({ body, previewText }); +const attachmentsKey = () => genSymmetricKey(); beforeEach(() => { vi.restoreAllMocks(); @@ -15,7 +16,7 @@ describe('buildEncryptionBlock + decryptEnvelope', () => { const recipients: RecipientPublicKey[] = [{ address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }]; - const envelope = await mailEncryption.buildEncryptionBlock(content('

hi bob

'), recipients); + const envelope = await mailEncryption.buildEncryptionBlock(content('

hi bob

'), recipients, attachmentsKey()); expect(envelope.version).toBe('v1'); expect(Array.isArray(envelope.wrappedKeys)).toBe(true); @@ -28,9 +29,11 @@ describe('buildEncryptionBlock + decryptEnvelope', () => { test('When a message is encrypted, then the subject is never part of the envelope', async () => { const bob = await generateEmailKeys(); - const envelope = await mailEncryption.buildEncryptionBlock(content('body'), [ - { address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }, - ]); + const envelope = await mailEncryption.buildEncryptionBlock( + content('body'), + [{ address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }], + attachmentsKey(), + ); expect(envelope).not.toHaveProperty('encryptedSubject'); }); @@ -39,10 +42,14 @@ describe('buildEncryptionBlock + decryptEnvelope', () => { const alice = await generateEmailKeys(); const bob = await generateEmailKeys(); - const envelope = await mailEncryption.buildEncryptionBlock(content('hey team'), [ - { address: 'alice@inxt.me', publicKey: uint8ArrayToBase64(alice.publicKey) }, - { address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }, - ]); + const envelope = await mailEncryption.buildEncryptionBlock( + content('hey team'), + [ + { address: 'alice@inxt.me', publicKey: uint8ArrayToBase64(alice.publicKey) }, + { address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }, + ], + attachmentsKey(), + ); const aliceView = await mailEncryption.decryptEnvelope(envelope, alice); const bobView = await mailEncryption.decryptEnvelope(envelope, bob); @@ -58,12 +65,16 @@ describe('buildEncryptionBlock + decryptEnvelope', () => { const bcc = await generateEmailKeys(); const addresses = ['sender@inxt.me', 'to@inxt.me', 'cc@inxt.me', 'secret-bcc@inxt.me']; - const envelope = await mailEncryption.buildEncryptionBlock(content('hidden recipients'), [ - { address: addresses[0], publicKey: uint8ArrayToBase64(sender.publicKey) }, - { address: addresses[1], publicKey: uint8ArrayToBase64(to.publicKey) }, - { address: addresses[2], publicKey: uint8ArrayToBase64(cc.publicKey) }, - { address: addresses[3], publicKey: uint8ArrayToBase64(bcc.publicKey) }, - ]); + const envelope = await mailEncryption.buildEncryptionBlock( + content('hidden recipients'), + [ + { address: addresses[0], publicKey: uint8ArrayToBase64(sender.publicKey) }, + { address: addresses[1], publicKey: uint8ArrayToBase64(to.publicKey) }, + { address: addresses[2], publicKey: uint8ArrayToBase64(cc.publicKey) }, + { address: addresses[3], publicKey: uint8ArrayToBase64(bcc.publicKey) }, + ], + attachmentsKey(), + ); const wire = `${ENCRYPTED_EMAIL_PREFIX}\n${Buffer.from(JSON.stringify(envelope)).toString('base64')}`; const serialized = JSON.stringify(envelope); @@ -85,15 +96,17 @@ describe('buildEncryptionBlock + decryptEnvelope', () => { }); test('When no recipients are provided, then encryption should fail', async () => { - await expect(mailEncryption.buildEncryptionBlock(content('t'), [])).rejects.toThrow(); + await expect(mailEncryption.buildEncryptionBlock(content('t'), [], attachmentsKey())).rejects.toThrow(); }); test('When decrypting with a key that was not a recipient, then decryption should fail cleanly', async () => { const bob = await generateEmailKeys(); const eve = await generateEmailKeys(); - const envelope = await mailEncryption.buildEncryptionBlock(content('y'), [ - { address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }, - ]); + const envelope = await mailEncryption.buildEncryptionBlock( + content('y'), + [{ address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }], + attachmentsKey(), + ); await expect(mailEncryption.decryptEnvelope(envelope, eve)).rejects.toThrow(/not a recipient or wrong key/); }); @@ -104,9 +117,11 @@ describe('encrypted preview', () => { const bob = await generateEmailKeys(); const previewText = `First line.\n\n Second line with spaces.${' tail'.repeat(200)}`; - const envelope = await mailEncryption.buildEncryptionBlock({ body: '

full body

', previewText }, [ - { address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }, - ]); + const envelope = await mailEncryption.buildEncryptionBlock( + { body: '

full body

', previewText }, + [{ address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }], + attachmentsKey(), + ); const preview = await mailEncryption.decryptSummaryPreview( { encryptedPreview: envelope.encryptedPreview, wrappedKeys: envelope.wrappedKeys }, @@ -121,9 +136,11 @@ describe('encrypted preview', () => { test('When a non-recipient tries to read the preview, then it fails cleanly', async () => { const bob = await generateEmailKeys(); const eve = await generateEmailKeys(); - const envelope = await mailEncryption.buildEncryptionBlock(content('

body

', 'snippet'), [ - { address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }, - ]); + const envelope = await mailEncryption.buildEncryptionBlock( + content('

body

', 'snippet'), + [{ address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }], + attachmentsKey(), + ); expect( await mailEncryption.decryptSummaryPreview( diff --git a/src/services/mail-encryption/index.ts b/src/services/mail-encryption/index.ts index 5edf414..1a6926b 100644 --- a/src/services/mail-encryption/index.ts +++ b/src/services/mail-encryption/index.ts @@ -1,4 +1,4 @@ -import { base64ToUint8Array, type HybridKeyPair } from 'internxt-crypto'; +import { base64ToUint8Array, encryptSymmetrically, type HybridKeyPair } from 'internxt-crypto'; import { decryptEmail, decryptKeysHybrid, @@ -13,6 +13,10 @@ export type RecipientPublicKey = { address: string; publicKey: string }; export type EmailContent = { body: string; previewText: string }; type WrappedKey = EncryptionBlock['wrappedKeys'][number]; export type EncryptedSummary = { encryptedPreview: string; wrappedKeys: WrappedKey[] }; +interface EncryptedAttachment { + sessionKey: Uint8Array; + encryptedFile: Uint8Array; +} const PREVIEW_PLAINTEXT_LENGTH = 256; export const ENCRYPTED_EMAIL_PREFIX = 'INTERNXT-ENCRYPTED-EMAIL-v1'; @@ -46,11 +50,20 @@ export class MailEncryptionService { * Only the body and preview are encrypted; the subject travels as cleartext so * the backend can index it. * - * The wrapped keys ship as a de-identified, order-randomized array carrying no - * recipient address, so the envelope hides the recipient set (Bcc included) — - * each recipient finds their entry by trial decryption (see `decryptEnvelope`). + * Both `wrappedKeys` and `attachmentWrappedKeys` ship as de-identified, + * order-randomized arrays carrying no recipient address, so the envelope + * hides the recipient set (Bcc included) — each recipient finds their entry + * by trial decryption (see `decryptEnvelope`). + * + * `attachmentsSessionKey` is the symmetric key used to encrypt every + * attachment blob in this email. It is wrapped per-recipient in + * `attachmentWrappedKeys`, kept separate from the body key on purpose. */ - async buildEncryptionBlock(content: EmailContent, recipients: RecipientPublicKey[]): Promise { + async buildEncryptionBlock( + content: EmailContent, + recipients: RecipientPublicKey[], + attachmentsSessionKey: Uint8Array, + ): Promise { if (recipients.length === 0) { throw new BuildEncryptionBlockError(); } @@ -62,25 +75,28 @@ export class MailEncryptionService { encryptionKey, ); - const wrapped = await Promise.all( - recipients.map(async (r) => { - const enc = await encryptKeysHybrid(encryptionKey, { - email: r.address, - publicHybridKey: base64ToUint8Array(r.publicKey), - }); - return { hybridCiphertext: enc.hybridCiphertext, encryptedKey: enc.encryptedKey }; - }), - ); - const wrappedKeys: WrappedKey[] = secureShuffle(wrapped); + const [wrappedKeys, attachmentWrappedKeys] = await Promise.all([ + Promise.all(recipients.map((r) => this.wrapKeyForRecipient(encryptionKey, r))).then(secureShuffle), + Promise.all(recipients.map((r) => this.wrapKeyForRecipient(attachmentsSessionKey, r))).then(secureShuffle), + ]); return { version: 'v1', encryptedText: encEmail.encText, encryptedPreview, wrappedKeys, + attachmentWrappedKeys, }; } + private async wrapKeyForRecipient(key: Uint8Array, recipient: RecipientPublicKey): Promise { + const enc = await encryptKeysHybrid(key, { + email: recipient.address, + publicHybridKey: base64ToUint8Array(recipient.publicKey), + }); + return { hybridCiphertext: enc.hybridCiphertext, encryptedKey: enc.encryptedKey }; + } + isEncryptedEmailBody(textBody: string | null | undefined): boolean { if (!textBody) return false; return textBody.startsWith(`${ENCRYPTED_EMAIL_PREFIX}\n`); @@ -136,4 +152,47 @@ export class MailEncryptionService { decryptSummaryPreview(summary: EncryptedSummary, keypair: HybridKeyPair): Promise { return this.trialDecrypt(summary.wrappedKeys, summary.encryptedPreview, keypair); } + + /** + * Trial-decrypts a wrapped key array and returns the raw key bytes. Same + * de-identified semantics as `trialDecrypt`, but stops at the unwrapped key + * rather than continuing to decrypt a payload with it. + */ + private async trialDecryptKey(wrappedKeys: WrappedKey[], keypair: HybridKeyPair): Promise { + for (const wrapped of wrappedKeys) { + try { + return await decryptKeysHybrid( + { hybridCiphertext: wrapped.hybridCiphertext, encryptedKey: wrapped.encryptedKey, encryptedForEmail: '' }, + keypair.secretKey, + ); + } catch { + // No op, try the next one. + } + } + throw new EnvelopeDecryptionError(); + } + + /** + * Recovers the symmetric session key used to encrypt every attachment in the + * email. Pair with `decryptSymmetrically` over the downloaded blob bytes. + * @throws {EnvelopeDecryptionError} if the caller is not a recipient. + */ + decryptAttachmentsSessionKey(envelope: EncryptionBlock, keypair: HybridKeyPair): Promise { + return this.trialDecryptKey(envelope.attachmentWrappedKeys, keypair); + } + + /** + * Encrypt the attachment + * @param sessionKey - The session key needed to encrypt the attachment + * @param file - The attachment we want to encrypt + * @returns - The encrypted attachment in the form of `EncryptedAttachment` + */ + async encryptAttachment(sessionKey: Uint8Array, file: File): Promise { + const fileBytes = new Uint8Array(await file.arrayBuffer()); + const encryptedFile = await encryptSymmetrically(sessionKey, fileBytes); + return { + sessionKey, + encryptedFile, + }; + } } diff --git a/src/services/network/index.ts b/src/services/network/index.ts new file mode 100644 index 0000000..5336273 --- /dev/null +++ b/src/services/network/index.ts @@ -0,0 +1,66 @@ +import { AxiosResponseError, AxiosUnknownError } from '@internxt/sdk/dist/shared/types/errors'; +import type { RequestCanceler } from '@internxt/sdk/dist/shared/http/types'; +import type { UploadAttachmentResponse } from '@internxt/sdk/dist/mail/types'; +import { MailService } from '@/services/sdk/mail'; +import { MailEncryptionService } from '../mail-encryption'; +import { decryptSymmetrically } from 'internxt-crypto'; + +const MAX_RETRIES = 2; + +export interface UploadOptions { + onCanceler?: (canceler: RequestCanceler) => void; + isCancelled?: () => boolean; +} + +export class NetworkService { + public static readonly instance: NetworkService = new NetworkService(); + + async upload(sessionKey: Uint8Array, file: File, options: UploadOptions = {}): Promise { + let lastError: unknown; + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + const encryptedFile = await this.encryptFile(sessionKey, file); + const { promise, requestCanceler } = MailService.instance.uploadAttachment(encryptedFile); + options.onCanceler?.(requestCanceler); + try { + return await promise; + } catch (error) { + lastError = error; + if (options.isCancelled?.()) throw error; + if (!this.isTransientError(error)) throw error; + } + } + throw lastError; + } + + async download({ + mailId, + blobId, + name, + type, + attachmentsSessionKey, + }: { + mailId: string; + blobId: string; + name: string; + type: string; + attachmentsSessionKey: Uint8Array; + }): Promise<{ blob: Blob; name: string }> { + const { data, contentType, fileName } = await MailService.instance.downloadAttachment(mailId, blobId, name, type); + const payload = await decryptSymmetrically(attachmentsSessionKey, new Uint8Array(data)); + const blob = new Blob([payload as BlobPart], { type: contentType || type }); + return { blob, name: name ?? fileName }; + } + + private async encryptFile(sessionKey: Uint8Array, file: File): Promise { + const { encryptedFile } = await MailEncryptionService.instance.encryptAttachment(sessionKey, file); + return new File([encryptedFile as BlobPart], file.name, { type: file.type }); + } + + private isTransientError(error: unknown): boolean { + if (error instanceof AxiosResponseError) return error.status >= 500; + if (error instanceof AxiosUnknownError) { + return error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT' || error.code === 'ERR_NETWORK'; + } + return false; + } +} diff --git a/src/services/network/network.test.ts b/src/services/network/network.test.ts new file mode 100644 index 0000000..941e8d7 --- /dev/null +++ b/src/services/network/network.test.ts @@ -0,0 +1,204 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { decryptSymmetrically } from 'internxt-crypto'; +import { NetworkService } from './index'; +import { MailService } from '@/services/sdk/mail'; +import { MailEncryptionService } from '@/services/mail-encryption'; +import type { UploadAttachmentResponse } from '@internxt/sdk/dist/mail/types'; +import { AxiosResponseError, AxiosUnknownError } from '@internxt/sdk/dist/shared/types/errors'; + +vi.mock('@/services/sdk/mail', () => ({ + MailService: { instance: { uploadAttachment: vi.fn(), downloadAttachment: vi.fn() } }, +})); + +vi.mock('@/services/mail-encryption', () => ({ + MailEncryptionService: { + instance: { + encryptAttachment: vi.fn(async (_key: Uint8Array, file: File) => ({ + sessionKey: new Uint8Array(32), + encryptedFile: new Uint8Array(await file.arrayBuffer()), + })), + }, + }, +})); + +vi.mock('internxt-crypto', () => ({ + decryptSymmetrically: vi.fn(async (_key: Uint8Array, bytes: Uint8Array) => bytes), +})); + +const uploadAttachment = vi.mocked(MailService.instance.uploadAttachment); +const downloadAttachment = vi.mocked(MailService.instance.downloadAttachment); +const encryptAttachment = vi.mocked(MailEncryptionService.instance.encryptAttachment); + +const aFile = (name = 'a.txt') => new File(['x'], name, { type: 'text/plain' }); +const SESSION_KEY = new Uint8Array(32); + +const mockSuccess = (result: UploadAttachmentResponse) => ({ + promise: Promise.resolve(result), + requestCanceler: { cancel: vi.fn() }, +}); + +const mockFailure = (error: unknown) => ({ + promise: Promise.reject(error), + requestCanceler: { cancel: vi.fn() }, +}); + +describe('Upload', () => { + beforeEach(() => { + uploadAttachment.mockReset(); + encryptAttachment.mockClear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('When the upload succeeds, then the encrypted file is sent and the result is returned', async () => { + const result = { blobId: 'blob-1', name: 'a.txt', size: 1, type: 'text/plain' }; + uploadAttachment.mockReturnValue(mockSuccess(result)); + + const got = await NetworkService.instance.upload(SESSION_KEY, aFile()); + + expect(encryptAttachment).toHaveBeenCalledTimes(1); + expect(encryptAttachment).toHaveBeenCalledWith(SESSION_KEY, expect.any(File)); + expect(uploadAttachment).toHaveBeenCalledTimes(1); + expect(got).toEqual(result); + }); + + test('When a canceler is exposed, then the consumer receives it through onCanceler', async () => { + const cancel = vi.fn(); + uploadAttachment.mockReturnValue({ + promise: Promise.resolve({ blobId: 'b', name: 'a.txt', size: 1, type: 'text/plain' }), + requestCanceler: { cancel }, + }); + const onCanceler = vi.fn(); + + await NetworkService.instance.upload(SESSION_KEY, aFile(), { onCanceler }); + + expect(onCanceler).toHaveBeenCalledWith({ cancel }); + }); + + test('When a transient 5xx error happens, then the upload is retried until success', async () => { + const serverError = new AxiosResponseError('Internal Server Error', 'POST /upload', { status: 503 } as never); + const result = { blobId: 'b', name: 'a.txt', size: 1, type: 'text/plain' }; + uploadAttachment + .mockReturnValueOnce(mockFailure(serverError)) + .mockReturnValueOnce(mockFailure(serverError)) + .mockReturnValue(mockSuccess(result)); + + const got = await NetworkService.instance.upload(SESSION_KEY, aFile()); + + expect(uploadAttachment).toHaveBeenCalledTimes(3); + expect(got).toEqual(result); + }); + + test('When a transient network error happens, then the upload is retried', async () => { + const networkError = new AxiosUnknownError('Network error', 'POST /upload', { code: 'ERR_NETWORK' } as never); + const result = { blobId: 'b', name: 'a.txt', size: 1, type: 'text/plain' }; + uploadAttachment.mockReturnValueOnce(mockFailure(networkError)).mockReturnValue(mockSuccess(result)); + + const got = await NetworkService.instance.upload(SESSION_KEY, aFile()); + + expect(uploadAttachment).toHaveBeenCalledTimes(2); + expect(got).toEqual(result); + }); + + test('When a non-transient 4xx error happens, then it is rethrown without retries', async () => { + const clientError = new AxiosResponseError('Forbidden', 'POST /upload', { status: 403 } as never); + uploadAttachment.mockReturnValue(mockFailure(clientError)); + + await expect(NetworkService.instance.upload(SESSION_KEY, aFile())).rejects.toBe(clientError); + expect(uploadAttachment).toHaveBeenCalledTimes(1); + }); + + test('When every retry fails transiently, then the last error is rethrown', async () => { + const serverError = new AxiosResponseError('Internal Server Error', 'POST /upload', { status: 503 } as never); + uploadAttachment.mockReturnValue(mockFailure(serverError)); + + await expect(NetworkService.instance.upload(SESSION_KEY, aFile())).rejects.toBe(serverError); + expect(uploadAttachment).toHaveBeenCalledTimes(3); + }); + + test('When isCancelled returns true after a failure, then the upload is not retried', async () => { + const serverError = new AxiosResponseError('Internal Server Error', 'POST /upload', { status: 503 } as never); + uploadAttachment.mockReturnValue(mockFailure(serverError)); + let cancelled = false; + const promise = NetworkService.instance.upload(SESSION_KEY, aFile(), { + isCancelled: () => cancelled, + }); + cancelled = true; + + await expect(promise).rejects.toBe(serverError); + expect(uploadAttachment).toHaveBeenCalledTimes(1); + }); +}); + +describe('Download', () => { + const decryptSymmetricallyMock = vi.mocked(decryptSymmetrically); + + const downloadInput = { + mailId: 'mail-1', + blobId: 'blob-1', + name: 'photo.jpg', + type: 'image/jpeg', + attachmentsSessionKey: new Uint8Array([1, 2, 3, 4]), + }; + + const encryptedBytes = new Uint8Array([9, 9, 9, 9]); + const plaintextBytes = new Uint8Array([1, 1, 1, 1]); + + beforeEach(() => { + downloadAttachment.mockReset(); + decryptSymmetricallyMock.mockReset(); + }); + + test('When the download succeeds, then the encrypted bytes are decrypted with the session key', async () => { + downloadAttachment.mockResolvedValue({ + data: encryptedBytes.buffer, + contentType: 'image/jpeg', + fileName: 'photo.jpg', + }); + decryptSymmetricallyMock.mockResolvedValue(plaintextBytes); + + const { blob, name } = await NetworkService.instance.download(downloadInput); + + expect(downloadAttachment).toHaveBeenCalledWith('mail-1', 'blob-1', 'photo.jpg', 'image/jpeg'); + expect(decryptSymmetricallyMock).toHaveBeenCalledWith(downloadInput.attachmentsSessionKey, expect.any(Uint8Array)); + expect(blob).toBeInstanceOf(Blob); + expect(blob.type).toBe('image/jpeg'); + expect(blob.size).toBe(plaintextBytes.byteLength); + expect(name).toBe('photo.jpg'); + }); + + test('When the response carries no contentType, then the attachment type is used as a fallback', async () => { + downloadAttachment.mockResolvedValue({ + data: encryptedBytes.buffer, + contentType: '', + fileName: 'photo.jpg', + }); + decryptSymmetricallyMock.mockResolvedValue(plaintextBytes); + + const { blob } = await NetworkService.instance.download(downloadInput); + + expect(blob.type).toBe('image/jpeg'); + }); + + test('When the underlying download fails, then the error is propagated', async () => { + const error = new Error('download failed'); + downloadAttachment.mockRejectedValue(error); + + await expect(NetworkService.instance.download(downloadInput)).rejects.toBe(error); + expect(decryptSymmetricallyMock).not.toHaveBeenCalled(); + }); + + test('When decryption fails, then the error is propagated', async () => { + downloadAttachment.mockResolvedValue({ + data: encryptedBytes.buffer, + contentType: 'image/jpeg', + fileName: 'photo.jpg', + }); + const cryptoError = new Error('AEAD tag mismatch'); + decryptSymmetricallyMock.mockRejectedValue(cryptoError); + + await expect(NetworkService.instance.download(downloadInput)).rejects.toBe(cryptoError); + }); +}); diff --git a/src/services/sdk/mail/mail.service.test.ts b/src/services/sdk/mail/mail.service.test.ts index c2eb309..b8f2021 100644 --- a/src/services/sdk/mail/mail.service.test.ts +++ b/src/services/sdk/mail/mail.service.test.ts @@ -327,6 +327,7 @@ describe('Mail Service', () => { encryptedText: 'enc-text', encryptedPreview: 'enc-preview', wrappedKeys: [{ hybridCiphertext: 'ct', encryptedKey: 'ek' }], + attachmentWrappedKeys: [{ hybridCiphertext: 'ct', encryptedKey: 'ek' }], }, }; const mockMailClient = { diff --git a/src/services/upload-manager/index.ts b/src/services/upload-manager/index.ts index 3cbd431..0c279e2 100644 --- a/src/services/upload-manager/index.ts +++ b/src/services/upload-manager/index.ts @@ -1,120 +1,82 @@ import { queue, type QueueObject } from 'async'; -import { AxiosResponseError, AxiosUnknownError } from '@internxt/sdk/dist/shared/types/errors'; -import { MailService } from '@/services/sdk/mail'; -import type { UploadAttachmentResponse } from '@internxt/sdk/dist/mail/types'; -import type { UploadAttachmentCallbacks, UploadAttachmentTask, UploadHandle } from '@/types/mail/upload-manager'; +import type { RequestCanceler } from '@internxt/sdk/dist/shared/http/types'; +import { NetworkService } from '../network'; const UPLOAD_CONCURRENCY = 4; -const MAX_RETRIES = 2; const CANCEL_REASON = 'Upload cancelled'; -export class UploadManager { - public static readonly instance: UploadManager = new UploadManager(); - - private readonly uploadFilesTasks: QueueObject; - private readonly tasks: Map = new Map(); - - private constructor() { - this.uploadFilesTasks = queue(this.uploadFileTask, UPLOAD_CONCURRENCY); - } +export interface UploadManagerCallbacks { + onSuccess: (id: string, blobId: string) => void; + onError: (id: string, error: unknown) => void; +} - private readonly uploadFileTask = async (task: UploadAttachmentTask) => { - if (task.cancelled) return; - try { - const result = await this.uploadWithRetries(task); - if (task.cancelled) return; - task.callbacks.onSuccess(task.id, result.blobId); - if (this.tasks.get(task.id) === task) this.tasks.delete(task.id); - } catch (error) { - if (task.cancelled) return; - task.failed = true; - task.callbacks.onError(task.id, error); - } - }; +interface UploadJob { + id: string; + file: File; + canceler?: RequestCanceler; + cancelled: boolean; +} - /** - * Starts the upload of the given files - * @param files - The files to upload - * @param callbacks - The callbacks to call when the upload is complete (success or error) - * @returns The uploaded ID and the file object - */ - run(files: File[], callbacks: UploadAttachmentCallbacks): UploadHandle[] { - return files.map((file) => { - const id = crypto.randomUUID(); - this.enqueueTask(id, file, callbacks); - return { id, file }; - }); - } +export class UploadManager { + private readonly jobs: Map = new Map(); + private readonly q: QueueObject; - /** - * The retry method is used to restart an upload that has failed - * @param id - The ID of the upload task - * @param callbacks - The callbacks to call when the upload is complete (success or error) - * @returns - void - */ - retry(id: string, callbacks: UploadAttachmentCallbacks): void { - const existing = this.tasks.get(id); - if (!existing) return; - this.cancelTask(existing); - this.enqueueTask(id, existing.file, callbacks); + constructor( + private readonly sessionKey: Uint8Array, + private readonly callbacks: UploadManagerCallbacks, + ) { + this.q = queue(this.process, UPLOAD_CONCURRENCY); } - /** - * The remove method is used to cancel an upload - * @param id - The ID of the upload task - * @returns - void - */ - remove(id: string): void { - const task = this.tasks.get(id); - if (!task) return; - this.cancelTask(task); - this.tasks.delete(id); + enqueue(id: string, file: File): void { + const job: UploadJob = { id, file, cancelled: false }; + this.jobs.set(id, job); + void this.q.push(job); } - /** - * The clear method is used to cancel all uploads - * @returns - void - */ - clear(): void { - this.tasks.forEach((task) => this.cancelTask(task)); - this.tasks.clear(); + retry(id: string): void { + const job = this.jobs.get(id); + if (!job) return; + job.cancelled = false; + job.canceler = undefined; + void this.q.push(job); } - private cancelTask(task: UploadAttachmentTask): void { - task.cancelled = true; - task.canceler?.cancel(CANCEL_REASON); - task.canceler = undefined; + cancel(id: string): void { + const job = this.jobs.get(id); + if (!job) return; + job.cancelled = true; + job.canceler?.cancel(CANCEL_REASON); + job.canceler = undefined; + this.jobs.delete(id); } - private enqueueTask(id: string, file: File, callbacks: UploadAttachmentCallbacks): void { - const task: UploadAttachmentTask = { id, file, callbacks, cancelled: false, failed: false }; - this.tasks.set(id, task); - void this.uploadFilesTasks.push(task); + clear(): void { + this.jobs.forEach((job) => { + job.cancelled = true; + job.canceler?.cancel(CANCEL_REASON); + }); + this.jobs.clear(); + this.q.remove(() => true); } - private async uploadWithRetries(task: UploadAttachmentTask): Promise { - let lastError: unknown; - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { - const { promise, requestCanceler } = MailService.instance.uploadAttachment(task.file); - task.canceler = requestCanceler; - try { - return await promise; - } catch (error) { - lastError = error; - if (task.cancelled) throw error; - if (!isTransientError(error)) throw error; - } finally { - task.canceler = undefined; - } + private readonly process = async (job: UploadJob): Promise => { + if (job.cancelled) return; + try { + const result = await NetworkService.instance.upload(this.sessionKey, job.file, { + onCanceler: (canceler) => { + job.canceler = canceler; + }, + isCancelled: () => job.cancelled, + }); + job.canceler = undefined; + if (job.cancelled) return; + this.jobs.delete(job.id); + this.callbacks.onSuccess(job.id, result.blobId); + } catch (error) { + job.canceler = undefined; + if (job.cancelled) return; + this.callbacks.onError(job.id, error); } - throw lastError; - } -} - -function isTransientError(error: unknown): boolean { - if (error instanceof AxiosResponseError) return error.status >= 500; - if (error instanceof AxiosUnknownError) { - return error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT' || error.code === 'ERR_NETWORK'; - } - return false; + }; } diff --git a/src/services/upload-manager/upload-manager.test.ts b/src/services/upload-manager/upload-manager.test.ts index d7241e7..adbdd2c 100644 --- a/src/services/upload-manager/upload-manager.test.ts +++ b/src/services/upload-manager/upload-manager.test.ts @@ -1,111 +1,94 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { UploadManager } from './index'; -import { MailService } from '@/services/sdk/mail'; -import type { UploadAttachmentResponse } from '@internxt/sdk/dist/mail/types'; -import { AxiosResponseError } from '@internxt/sdk/dist/shared/types/errors'; +import { UploadManager, type UploadManagerCallbacks } from './index'; +import { NetworkService } from '@/services/network'; -vi.mock('@/services/sdk/mail', () => ({ - MailService: { instance: { uploadAttachment: vi.fn() } }, +vi.mock('@/services/network', () => ({ + NetworkService: { instance: { upload: vi.fn() } }, })); -const uploadAttachment = vi.mocked(MailService.instance.uploadAttachment); +const upload = vi.mocked(NetworkService.instance.upload); const aFile = (name = 'a.txt') => new File(['x'], name, { type: 'text/plain' }); +const SESSION_KEY = new Uint8Array(32); const flush = async () => { - for (let i = 0; i < 10; i++) await Promise.resolve(); + for (let i = 0; i < 20; i++) await Promise.resolve(); }; -const mockSuccess = (result: UploadAttachmentResponse) => ({ - promise: Promise.resolve(result), - requestCanceler: { cancel: vi.fn() }, -}); - -const mockFailure = (error: unknown) => ({ - promise: Promise.reject(error), - requestCanceler: { cancel: vi.fn() }, -}); +const newManager = (callbacks: Partial = {}) => + new UploadManager(SESSION_KEY, { + onSuccess: vi.fn(), + onError: vi.fn(), + ...callbacks, + }); describe('Upload Manager', () => { beforeEach(() => { - vi.restoreAllMocks(); - uploadAttachment.mockReset(); + upload.mockReset(); }); afterEach(() => { - vi.useRealTimers(); + vi.restoreAllMocks(); }); - describe('run', () => { - test('When files are pushed, then each pushed file yields a distinct stable handle', () => { - uploadAttachment.mockReturnValue(mockSuccess({ blobId: 'b', name: 'a.txt', size: 1, type: 'text/plain' })); - const f1 = aFile('1.txt'); - const f2 = aFile('2.txt'); - - const handles = UploadManager.instance.run([f1, f2], { onSuccess: vi.fn(), onError: vi.fn() }); - - expect(handles).toHaveLength(2); - expect(handles[0].file).toBe(f1); - expect(handles[1].file).toBe(f2); - expect(handles[0].id).not.toBe(handles[1].id); - }); - - test('When upload succeeds, then the caller is notified with the upload result', async () => { - const result = { blobId: 'blob-1', name: 'a.txt', size: 1, type: 'text/plain' }; - uploadAttachment.mockReturnValue(mockSuccess(result)); + describe('enqueue', () => { + test('When the upload resolves, then onSuccess is called with the resulting blobId', async () => { + upload.mockResolvedValue({ blobId: 'blob-1', name: 'a.txt', size: 1, type: 'text/plain' }); const onSuccess = vi.fn(); const onError = vi.fn(); + const manager = newManager({ onSuccess, onError }); - const [{ id }] = UploadManager.instance.run([aFile()], { onSuccess, onError }); + manager.enqueue('id-0', aFile()); await flush(); - expect(onSuccess).toHaveBeenCalledWith(id, result.blobId); + expect(onSuccess).toHaveBeenCalledWith('id-0', 'blob-1'); expect(onError).not.toHaveBeenCalled(); }); - test('When upload fails on all attempts, then error is handled properly', async () => { + test('When the upload rejects, then onError is called with the error', async () => { const error = new Error('boom'); - uploadAttachment.mockReturnValue(mockFailure(error)); + upload.mockRejectedValue(error); const onSuccess = vi.fn(); const onError = vi.fn(); + const manager = newManager({ onSuccess, onError }); - const [{ id }] = UploadManager.instance.run([aFile()], { onSuccess, onError }); + manager.enqueue('id-0', aFile()); await flush(); - expect(onError).toHaveBeenCalledWith(id, error); + expect(onError).toHaveBeenCalledWith('id-0', error); expect(onSuccess).not.toHaveBeenCalled(); }); - test('When upload fails transiently, then it retries until success (max 3 attempts)', async () => { - const result = { blobId: 'b', name: 'a.txt', size: 1, type: 'text/plain' }; - const serverError = new AxiosResponseError('Internal Server Error', 'POST /upload', { status: 503 } as never); - uploadAttachment - .mockReturnValueOnce(mockFailure(serverError)) - .mockReturnValueOnce(mockFailure(serverError)) - .mockReturnValue(mockSuccess(result)); - const onSuccess = vi.fn(); - const onError = vi.fn(); - - UploadManager.instance.run([aFile()], { onSuccess, onError }); + test('When multiple files are enqueued, then the queue caps in-flight uploads at the configured concurrency', async () => { + const releases: Array<() => void> = []; + upload.mockImplementation( + () => + new Promise((resolve) => { + releases.push(() => resolve({ blobId: 'b', name: 'a.txt', size: 1, type: 'text/plain' })); + }), + ); + const manager = newManager(); + + for (let i = 0; i < 6; i++) manager.enqueue(`id-${i}`, aFile(`${i}.txt`)); await flush(); - expect(uploadAttachment).toHaveBeenCalledTimes(3); - expect(onSuccess).toHaveBeenCalledTimes(1); - expect(onError).not.toHaveBeenCalled(); + expect(upload).toHaveBeenCalledTimes(4); + + releases[0](); + releases[1](); + await flush(); + expect(upload).toHaveBeenCalledTimes(6); }); - test('When upload fails with a 4xx error, then it does not retry and calls onError immediately', async () => { - const clientError = new AxiosResponseError('Forbidden', 'POST /upload', { status: 403 } as never); - uploadAttachment.mockReturnValue(mockFailure(clientError)); - const onSuccess = vi.fn(); - const onError = vi.fn(); + test('When the file is forwarded to NetworkService, then the session key is passed through', async () => { + upload.mockResolvedValue({ blobId: 'b', name: 'a.txt', size: 1, type: 'text/plain' }); + const file = aFile(); + const manager = newManager(); - const [{ id }] = UploadManager.instance.run([aFile()], { onSuccess, onError }); + manager.enqueue('id-0', file); await flush(); - expect(uploadAttachment).toHaveBeenCalledTimes(1); - expect(onError).toHaveBeenCalledWith(id, clientError); - expect(onSuccess).not.toHaveBeenCalled(); + expect(upload).toHaveBeenCalledWith(SESSION_KEY, file, expect.any(Object)); }); }); @@ -113,75 +96,113 @@ describe('Upload Manager', () => { test('When retry is called with an unknown id, then nothing happens', async () => { const onSuccess = vi.fn(); const onError = vi.fn(); + const manager = newManager({ onSuccess, onError }); - UploadManager.instance.retry('does-not-exist', { onSuccess, onError }); + manager.retry('does-not-exist'); await flush(); - expect(uploadAttachment).not.toHaveBeenCalled(); + expect(upload).not.toHaveBeenCalled(); expect(onSuccess).not.toHaveBeenCalled(); expect(onError).not.toHaveBeenCalled(); }); test('When retry is called after a failed upload, then the caller is notified of the eventual success', async () => { - uploadAttachment.mockReturnValue(mockFailure(new Error('initial failure'))); - const initialError = vi.fn(); - const [{ id }] = UploadManager.instance.run([aFile()], { onSuccess: vi.fn(), onError: initialError }); - await flush(); - expect(initialError).toHaveBeenCalledWith(id, expect.any(Error)); - - const result = { blobId: 'retry-blob', name: 'a.txt', size: 1, type: 'text/plain' }; - uploadAttachment.mockReturnValue(mockSuccess(result)); + upload.mockRejectedValueOnce(new Error('initial failure')); const onSuccess = vi.fn(); const onError = vi.fn(); + const manager = newManager({ onSuccess, onError }); - UploadManager.instance.retry(id, { onSuccess, onError }); + manager.enqueue('id-0', aFile()); await flush(); + expect(onError).toHaveBeenCalledWith('id-0', expect.any(Error)); + + upload.mockResolvedValueOnce({ blobId: 'retry-blob', name: 'a.txt', size: 1, type: 'text/plain' }); + onSuccess.mockReset(); + onError.mockReset(); - expect(onSuccess).toHaveBeenCalledWith(id, result.blobId); + manager.retry('id-0'); + await flush(); + + expect(onSuccess).toHaveBeenCalledWith('id-0', 'retry-blob'); expect(onError).not.toHaveBeenCalled(); }); }); - describe('remove', () => { - test('When remove is called before the upload starts, then callbacks are never invoked', async () => { - let resolveBlocker: (() => void) | undefined; - const blocker = new Promise((res) => { - resolveBlocker = () => res({ blobId: 'first', name: 'a.txt', size: 1, type: 'text/plain' }); - }); - uploadAttachment.mockReturnValueOnce({ promise: blocker, requestCanceler: { cancel: vi.fn() } }); - uploadAttachment.mockReturnValue(mockSuccess({ blobId: 'never', name: 'b', size: 1, type: 'text/plain' })); + describe('cancel', () => { + test('When cancel is called before the upload starts, then its callbacks are never invoked', async () => { + const releasers: Record void> = {}; + upload.mockImplementation( + (_key, file) => + new Promise((resolve) => { + releasers[file.name] = () => resolve({ blobId: file.name, name: file.name, size: 1, type: 'text/plain' }); + }), + ); const onSuccess = vi.fn(); const onError = vi.fn(); - const [first, second] = UploadManager.instance.run([aFile('1.txt'), aFile('2.txt')], { onSuccess, onError }); + const manager = newManager({ onSuccess, onError }); - UploadManager.instance.remove(second.id); - resolveBlocker?.(); + manager.enqueue('first', aFile('1.txt')); + manager.enqueue('second', aFile('2.txt')); + await flush(); + + manager.cancel('second'); + releasers['1.txt']?.(); + releasers['2.txt']?.(); await flush(); const calls = [...onSuccess.mock.calls, ...onError.mock.calls]; - expect(calls.some(([id]) => id === second.id)).toBe(false); - expect(onSuccess).toHaveBeenCalledWith(first.id, expect.any(String)); + expect(calls.some(([id]) => id === 'second')).toBe(false); + expect(onSuccess).toHaveBeenCalledWith('first', '1.txt'); }); - test('When remove is called while the upload is in flight, then the in-flight request is aborted', async () => { - const cancel = vi.fn(); - let resolveBlocker: (() => void) | undefined; - const blocker = new Promise((_res, rej) => { - resolveBlocker = () => rej(new Error('aborted')); + test('When cancel is called while the upload is in flight, then the in-flight canceler is triggered', async () => { + const cancelSpy = vi.fn(); + let rejectFirst: ((reason: unknown) => void) | undefined; + upload.mockImplementationOnce((_key, _file, options) => { + options?.onCanceler?.({ cancel: cancelSpy }); + return new Promise((_resolve, reject) => { + rejectFirst = reject; + }); }); - uploadAttachment.mockReturnValueOnce({ promise: blocker, requestCanceler: { cancel } }); const onSuccess = vi.fn(); const onError = vi.fn(); - const [{ id }] = UploadManager.instance.run([aFile()], { onSuccess, onError }); + const manager = newManager({ onSuccess, onError }); + + manager.enqueue('id-0', aFile()); + await flush(); + manager.cancel('id-0'); + rejectFirst?.(new Error('aborted')); + await flush(); + + expect(cancelSpy).toHaveBeenCalledWith('Upload cancelled'); + expect(onSuccess).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + }); + }); + + describe('clear', () => { + test('When clear is called, then every in-flight job is cancelled and no callbacks fire afterwards', async () => { + const cancelSpies: Array> = []; + upload.mockImplementation((_key, _file, options) => { + const cancel = vi.fn(); + cancelSpies.push(cancel); + options?.onCanceler?.({ cancel }); + return new Promise(() => {}); + }); + const onSuccess = vi.fn(); + const onError = vi.fn(); + const manager = newManager({ onSuccess, onError }); + + manager.enqueue('id-0', aFile('1.txt')); + manager.enqueue('id-1', aFile('2.txt')); await flush(); - UploadManager.instance.remove(id); - resolveBlocker?.(); + manager.clear(); await flush(); - expect(cancel).toHaveBeenCalledWith('Upload cancelled'); + cancelSpies.forEach((cancel) => expect(cancel).toHaveBeenCalledWith('Upload cancelled')); expect(onSuccess).not.toHaveBeenCalled(); expect(onError).not.toHaveBeenCalled(); }); diff --git a/src/types/mail/upload-manager/index.ts b/src/types/mail/upload-manager/index.ts deleted file mode 100644 index c89ec4b..0000000 --- a/src/types/mail/upload-manager/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { RequestCanceler } from '@internxt/sdk/dist/shared/http/types'; - -export type UploadAttachmentCallbacks = { - onSuccess: (id: string, blobId: string) => void; - onError: (id: string, error: unknown) => void; -}; - -export type UploadHandle = { - id: string; - file: File; -}; - -export type UploadAttachmentTask = { - id: string; - file: File; - callbacks: UploadAttachmentCallbacks; - cancelled: boolean; - failed: boolean; - canceler?: RequestCanceler; -};