@@ -40,13 +73,23 @@ const Preview = ({ subject, body, attachments }: PreviewProps) => {
- {attachments?.map((attachment) => (
-
-
- {attachment}
-
+ {attachments && attachments.length > 0 && (
+
+ {attachments.map((attachment) => (
+
+
+
{attachment.name}
+
{bytesToString({ size: attachment.size })}
+
onDownload(attachment)}
+ />
+
+ ))}
- ))}
+ )}
);
};
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index cbc2b19..1679b5c 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -118,6 +118,9 @@
"bcc": "BCC",
"decrypting": "Decrypting message…",
"decryptFailed": "Could not decrypt this message",
+ "errors": {
+ "downloadAttachmentFailed": "Could not download the attachment"
+ },
"emptyEmail": {
"unreadEmails": {
"title": "Unread messages",
@@ -195,7 +198,16 @@
"subject": "Subject",
"message": "Message",
"encryptedBadge": "End-to-end encrypted",
- "cleartextBadge": "Not encrypted"
+ "cleartextBadge": "Not encrypted",
+ "errors": {
+ "noRecipients": "Add at least one recipient before sending",
+ "sendFailed": "Could not send the email",
+ "keyLookupFailed": "Could not fetch recipient keys",
+ "attachmentsTooLarge": "Attachments exceed the 25 MB limit"
+ },
+ "attachments": {
+ "totalSize": "{{used}} of {{max}}"
+ }
},
"preferences": {
"title": "Preferences",
@@ -235,4 +247,4 @@
"movedToFolder_many": "{{count}} conversations moved to \"{{folder}}\"",
"undo": "Undo"
}
-}
\ No newline at end of file
+}
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index 6fcbcc8..475a01c 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -120,6 +120,9 @@
"bcc": "CCO",
"decrypting": "Descifrando mensaje…",
"decryptFailed": "No se pudo descifrar este mensaje",
+ "errors": {
+ "downloadAttachmentFailed": "No se pudo descargar el adjunto"
+ },
"emptyEmail": {
"unreadEmails": {
"title": "Mensajes sin leer",
@@ -197,7 +200,16 @@
"subject": "Asunto",
"message": "Mensaje",
"encryptedBadge": "Cifrado de extremo a extremo",
- "cleartextBadge": "Sin cifrar"
+ "cleartextBadge": "Sin cifrar",
+ "errors": {
+ "noRecipients": "Agrega al menos un destinatario antes de enviar",
+ "sendFailed": "No se pudo enviar el correo",
+ "keyLookupFailed": "No se pudieron obtener las claves del destinatario",
+ "attachmentsTooLarge": "Los adjuntos superan el límite de 25 MB"
+ },
+ "attachments": {
+ "totalSize": "{{used}} de {{max}}"
+ }
},
"preferences": {
"title": "Preferencias",
@@ -237,4 +249,4 @@
"movedToFolder_many": "{{count}} conversaciones movidas a \"{{folder}}\"",
"undo": "Deshacer"
}
-}
\ No newline at end of file
+}
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index 108f4db..90058a6 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -120,6 +120,9 @@
"bcc": "CCI",
"decrypting": "Déchiffrement du message…",
"decryptFailed": "Impossible de déchiffrer ce message",
+ "errors": {
+ "downloadAttachmentFailed": "Impossible de télécharger la pièce jointe"
+ },
"emptyEmail": {
"unreadEmails": {
"title": "Messages non lus",
@@ -197,7 +200,16 @@
"subject": "Objet",
"message": "Message",
"encryptedBadge": "Chiffré de bout en bout",
- "cleartextBadge": "Non chiffré"
+ "cleartextBadge": "Non chiffré",
+ "errors": {
+ "noRecipients": "Ajoutez au moins un destinataire avant d'envoyer",
+ "sendFailed": "Impossible d'envoyer le courriel",
+ "keyLookupFailed": "Impossible de récupérer les clés du destinataire",
+ "attachmentsTooLarge": "Les pièces jointes dépassent la limite de 25 Mo"
+ },
+ "attachments": {
+ "totalSize": "{{used}} sur {{max}}"
+ }
},
"preferences": {
"title": "Préférences",
@@ -237,4 +249,4 @@
"movedToFolder_many": "{{count}} conversations déplacées vers « {{folder}} »",
"undo": "Annuler"
}
-}
\ No newline at end of file
+}
diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json
index 8f4df58..83ac6db 100644
--- a/src/i18n/locales/it.json
+++ b/src/i18n/locales/it.json
@@ -120,6 +120,9 @@
"bcc": "CCN",
"decrypting": "Decrittazione del messaggio…",
"decryptFailed": "Impossibile decrittare questo messaggio",
+ "errors": {
+ "downloadAttachmentFailed": "Impossibile scaricare l'allegato"
+ },
"emptyEmail": {
"unreadEmails": {
"title": "Messaggi non letti",
@@ -197,7 +200,16 @@
"subject": "Oggetto",
"message": "Messaggio",
"encryptedBadge": "Crittografia end-to-end",
- "cleartextBadge": "Non crittografato"
+ "cleartextBadge": "Non crittografato",
+ "errors": {
+ "noRecipients": "Aggiungi almeno un destinatario prima di inviare",
+ "sendFailed": "Impossibile inviare l'email",
+ "keyLookupFailed": "Impossibile recuperare le chiavi del destinatario",
+ "attachmentsTooLarge": "Gli allegati superano il limite di 25 MB"
+ },
+ "attachments": {
+ "totalSize": "{{used}} di {{max}}"
+ }
},
"preferences": {
"title": "Preferenze",
@@ -237,4 +249,4 @@
"movedToFolder_many": "{{count}} conversazioni spostate in \"{{folder}}\"",
"undo": "Annulla"
}
-}
\ No newline at end of file
+}
diff --git a/src/services/sdk/mail/index.ts b/src/services/sdk/mail/index.ts
index 0d28d3d..27ffc31 100644
--- a/src/services/sdk/mail/index.ts
+++ b/src/services/sdk/mail/index.ts
@@ -1,4 +1,5 @@
import type {
+ DownloadAttachmentResponse,
EmailCreatedResponse,
EmailDomainsResponse,
EmailListResponse,
@@ -12,6 +13,7 @@ import type {
SendEmailRequest,
SetupMailAccountPayload,
UpdateEmailRequest,
+ UploadAttachmentResponse,
} from '@internxt/sdk/dist/mail/types';
import { SdkManager } from '..';
@@ -142,4 +144,20 @@ export class MailService {
async lookupRecipientKeys(addresses: string[]): Promise
{
return this.client.lookupRecipientKeys(addresses);
}
+
+ async uploadAttachment(file: File): Promise {
+ return this.client.uploadAttachment(file);
+ }
+
+ async downloadAttachment(
+ mailId: string,
+ blobId: string,
+ mailName: string,
+ mailType: string,
+ ): Promise {
+ return this.client.downloadAttachment(mailId, blobId, {
+ name: mailName,
+ type: mailType,
+ });
+ }
}
diff --git a/src/services/upload-manager/index.ts b/src/services/upload-manager/index.ts
new file mode 100644
index 0000000..4b549fe
--- /dev/null
+++ b/src/services/upload-manager/index.ts
@@ -0,0 +1,84 @@
+import { queue, type QueueObject } from 'async';
+import { MailService } from '@/services/sdk/mail';
+import type { UploadAttachmentResponse } from '@internxt/sdk/dist/mail/types';
+
+export type UploadAttachmentCallbacks = {
+ onSuccess: (id: string, result: UploadAttachmentResponse) => void;
+ onError: (id: string, error: unknown) => void;
+};
+
+export type UploadHandle = {
+ id: string;
+ file: File;
+};
+
+type UploadAttachmentTask = {
+ id: string;
+ file: File;
+ callbacks: UploadAttachmentCallbacks;
+ cancelled: boolean;
+};
+
+const UPLOAD_CONCURRENCY = 4;
+const MAX_RETRIES = 2;
+
+export class UploadManager {
+ public static readonly instance: UploadManager = new UploadManager();
+
+ private readonly uploadFiles: QueueObject;
+ private readonly tasks: Map = new Map();
+
+ private constructor() {
+ this.uploadFiles = queue(async (task) => {
+ if (task.cancelled) return;
+ try {
+ const result = await this.uploadWithRetries(task.file);
+ if (!task.cancelled) task.callbacks.onSuccess(task.id, result);
+ } catch (error) {
+ if (!task.cancelled) task.callbacks.onError(task.id, error);
+ } finally {
+ if (this.tasks.get(task.id) === task) this.tasks.delete(task.id);
+ }
+ }, UPLOAD_CONCURRENCY);
+ }
+
+ run(files: File[], callbacks: UploadAttachmentCallbacks): UploadHandle[] {
+ return files.map((file) => {
+ const id = crypto.randomUUID();
+ this.enqueue(id, file, callbacks);
+ return { id, file };
+ });
+ }
+
+ retry(id: string, callbacks: UploadAttachmentCallbacks): void {
+ const existing = this.tasks.get(id);
+ if (!existing) return;
+ existing.cancelled = true;
+ this.enqueue(id, existing.file, callbacks);
+ }
+
+ remove(id: string): void {
+ const task = this.tasks.get(id);
+ if (!task) return;
+ task.cancelled = true;
+ this.tasks.delete(id);
+ }
+
+ private enqueue(id: string, file: File, callbacks: UploadAttachmentCallbacks): void {
+ const task: UploadAttachmentTask = { id, file, callbacks, cancelled: false };
+ this.tasks.set(id, task);
+ void this.uploadFiles.push(task);
+ }
+
+ private async uploadWithRetries(file: File): Promise {
+ let lastError: unknown;
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
+ try {
+ return await MailService.instance.uploadAttachment(file);
+ } catch (error) {
+ lastError = error;
+ }
+ }
+ throw lastError;
+ }
+}
From fcd78e25f66c9b55d4080120c73858f5e47a69c4 Mon Sep 17 00:00:00 2001
From: Xavier Abad <77491413+xabg2@users.noreply.github.com>
Date: Tue, 2 Jun 2026 15:42:24 +0200
Subject: [PATCH 2/8] fix: use Attachment type from SDK
---
.../compose-message/components/AttachmentList.tsx | 4 ++--
.../compose-message/hooks/useAttachments.ts | 14 ++++++--------
src/components/compose-message/index.tsx | 2 +-
3 files changed, 9 insertions(+), 11 deletions(-)
diff --git a/src/components/compose-message/components/AttachmentList.tsx b/src/components/compose-message/components/AttachmentList.tsx
index aaeb6d2..8915c74 100644
--- a/src/components/compose-message/components/AttachmentList.tsx
+++ b/src/components/compose-message/components/AttachmentList.tsx
@@ -1,10 +1,10 @@
import { ArrowClockwiseIcon, PaperclipIcon, SpinnerIcon, WarningIcon, XIcon } from '@phosphor-icons/react';
import { bytesToString } from '@/utils/bytes-to-string';
import { useTranslationContext } from '@/i18n';
-import { MAX_TOTAL_ATTACHMENT_BYTES, type Attachment } from '../hooks/useAttachments';
+import { MAX_TOTAL_ATTACHMENT_BYTES, type AttachmentTask } from '../hooks/useAttachments';
interface AttachmentListProps {
- attachments: Attachment[];
+ attachments: AttachmentTask[];
totalSize: number;
onRemove: (id: string) => void;
onRetry: (id: string) => void;
diff --git a/src/components/compose-message/hooks/useAttachments.ts b/src/components/compose-message/hooks/useAttachments.ts
index 38265f8..cb1612a 100644
--- a/src/components/compose-message/hooks/useAttachments.ts
+++ b/src/components/compose-message/hooks/useAttachments.ts
@@ -2,23 +2,21 @@ import { useCallback, useMemo, useState } from 'react';
import { useTranslationContext } from '@/i18n';
import notificationsService, { ToastType } from '@/services/notifications';
import { UploadManager, type UploadAttachmentCallbacks } from '@/services/upload-manager';
+import type { AttachmentRef } from '@internxt/sdk/dist/mail/types';
export type AttachmentStatus = 'uploading' | 'done' | 'error';
-export type Attachment = {
+export interface AttachmentTask extends Omit {
id: string;
- name: string;
- size: number;
- mimeType: string;
status: AttachmentStatus;
blobId?: string;
-};
+}
export const MAX_TOTAL_ATTACHMENT_BYTES = 25 * 1024 * 1024;
const useAttachments = () => {
const { translate } = useTranslationContext();
- const [attachments, setAttachments] = useState([]);
+ const [attachments, setAttachments] = useState([]);
const totalSize = useMemo(() => attachments.reduce((s, a) => s + a.size, 0), [attachments]);
const isUploading = useMemo(() => attachments.some((a) => a.status === 'uploading'), [attachments]);
@@ -45,11 +43,11 @@ const useAttachments = () => {
return;
}
const handles = UploadManager.instance.run(list, callbacks);
- const pending: Attachment[] = handles.map(({ id, file }) => ({
+ const pending: AttachmentTask[] = handles.map(({ id, file }) => ({
id,
name: file.name,
size: file.size,
- mimeType: file.type || 'application/octet-stream',
+ type: file.type ?? 'application/octet-stream',
status: 'uploading',
}));
setAttachments((prev) => [...prev, ...pending]);
diff --git a/src/components/compose-message/index.tsx b/src/components/compose-message/index.tsx
index c53fcb0..0e1ea16 100644
--- a/src/components/compose-message/index.tsx
+++ b/src/components/compose-message/index.tsx
@@ -117,7 +117,7 @@ export const ComposeMessageDialog = () => {
blobId: a.blobId as string,
name: a.name,
size: a.size,
- type: a.mimeType,
+ type: a.type,
}));
const htmlBody = editor?.getHTML() ?? '';
From e8ca081ee68cf99b9201dc9a31907470b784b767 Mon Sep 17 00:00:00 2001
From: Xavier Abad <77491413+xabg2@users.noreply.github.com>
Date: Tue, 2 Jun 2026 15:52:53 +0200
Subject: [PATCH 3/8] test: add coverage to the upload manager and the
attachment custom hook
---
.../hooks/useAttachments.test.ts | 155 ++++++++++++++++++
.../upload-manager/upload-manager.test.ts | 122 ++++++++++++++
2 files changed, 277 insertions(+)
create mode 100644 src/components/compose-message/hooks/useAttachments.test.ts
create mode 100644 src/services/upload-manager/upload-manager.test.ts
diff --git a/src/components/compose-message/hooks/useAttachments.test.ts b/src/components/compose-message/hooks/useAttachments.test.ts
new file mode 100644
index 0000000..fd5a31e
--- /dev/null
+++ b/src/components/compose-message/hooks/useAttachments.test.ts
@@ -0,0 +1,155 @@
+import { renderHook, act } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+import useAttachments, { MAX_TOTAL_ATTACHMENT_BYTES } from './useAttachments';
+import { UploadManager, type UploadAttachmentCallbacks, type UploadHandle } from '@/services/upload-manager';
+import notificationsService, { ToastType } from '@/services/notifications';
+
+vi.mock('@/i18n', () => ({ useTranslationContext: () => ({ translate: (key: string) => key }) }));
+
+vi.mock('@/services/notifications', () => ({
+ default: { show: vi.fn() },
+ ToastType: { Success: 'success', Error: 'error', Warning: 'warning', Info: 'info', Loading: 'loading' },
+}));
+
+vi.mock('@/services/upload-manager', () => ({
+ UploadManager: { instance: { run: vi.fn(), retry: vi.fn(), remove: vi.fn() } },
+}));
+
+const run = vi.mocked(UploadManager.instance.run);
+const retry = vi.mocked(UploadManager.instance.retry);
+const remove = vi.mocked(UploadManager.instance.remove);
+const show = vi.mocked(notificationsService.show);
+
+let lastCallbacks: UploadAttachmentCallbacks | undefined;
+
+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();
+ show.mockReset();
+ lastCallbacks = undefined;
+ run.mockImplementation((files: File[], callbacks: UploadAttachmentCallbacks): UploadHandle[] => {
+ lastCallbacks = callbacks;
+ return files.map((file, i) => ({ id: `id-${i}`, file }));
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ 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());
+
+ act(() => result.current.addFiles([f1, f2]));
+
+ expect(run).toHaveBeenCalledWith([f1, f2], expect.any(Object));
+ 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' },
+ ]);
+ expect(result.current.totalSize).toBe(300);
+ expect(result.current.isUploading).toBe(true);
+ expect(result.current.hasErrors).toBe(false);
+ });
+
+ test('When the new batch would exceed 25 MB total, then it shows a warning toast and does not enqueue', () => {
+ const big = fileOfSize(MAX_TOTAL_ATTACHMENT_BYTES + 1);
+ const { result } = renderHook(() => useAttachments());
+
+ act(() => result.current.addFiles([big]));
+
+ expect(show).toHaveBeenCalledWith({
+ text: 'modals.composeMessageDialog.errors.attachmentsTooLarge',
+ type: ToastType.Warning,
+ });
+ expect(run).not.toHaveBeenCalled();
+ expect(result.current.attachments).toHaveLength(0);
+ });
+
+ test('When the cumulative size reaches the 25 MB limit, then a subsequent batch is rejected', () => {
+ const half = fileOfSize(MAX_TOTAL_ATTACHMENT_BYTES / 2);
+ const { result } = renderHook(() => useAttachments());
+
+ act(() => result.current.addFiles([half]));
+ act(() => result.current.addFiles([fileOfSize(MAX_TOTAL_ATTACHMENT_BYTES / 2 + 1)]));
+
+ expect(result.current.attachments).toHaveLength(1);
+ expect(show).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ 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());
+ act(() => result.current.addFiles([fileOfSize(10)]));
+
+ act(() => lastCallbacks?.onSuccess('id-0', { blobId: 'blob-1', name: 'a.txt', size: 10, type: 'text/plain' }));
+
+ expect(result.current.attachments[0]).toMatchObject({ status: 'done', blobId: 'blob-1' });
+ expect(result.current.isUploading).toBe(false);
+ expect(result.current.hasErrors).toBe(false);
+ });
+
+ test('When the manager reports an error, then the attachment moves to error', () => {
+ const { result } = renderHook(() => useAttachments());
+ act(() => result.current.addFiles([fileOfSize(10)]));
+
+ act(() => lastCallbacks?.onError('id-0', new Error('boom')));
+
+ expect(result.current.attachments[0].status).toBe('error');
+ expect(result.current.hasErrors).toBe(true);
+ expect(result.current.isUploading).toBe(false);
+ });
+ });
+
+ 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());
+ 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(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());
+ act(() => result.current.addFiles([fileOfSize(10, '1.txt'), fileOfSize(20, '2.txt')]));
+
+ act(() => result.current.remove('id-0'));
+
+ expect(remove).toHaveBeenCalledWith('id-0');
+ expect(result.current.attachments).toHaveLength(1);
+ expect(result.current.attachments[0].id).toBe('id-1');
+ });
+ });
+
+ describe('clear', () => {
+ test('When clear is called, then every attachment is removed from the manager and state', () => {
+ const { result } = renderHook(() => useAttachments());
+ act(() => result.current.addFiles([fileOfSize(10, '1.txt'), fileOfSize(20, '2.txt')]));
+
+ act(() => result.current.clear());
+
+ expect(remove).toHaveBeenCalledTimes(2);
+ expect(remove).toHaveBeenCalledWith('id-0');
+ expect(remove).toHaveBeenCalledWith('id-1');
+ expect(result.current.attachments).toHaveLength(0);
+ });
+ });
+});
diff --git a/src/services/upload-manager/upload-manager.test.ts b/src/services/upload-manager/upload-manager.test.ts
new file mode 100644
index 0000000..fb4eddb
--- /dev/null
+++ b/src/services/upload-manager/upload-manager.test.ts
@@ -0,0 +1,122 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+import { UploadManager } from './index';
+import { MailService } from '@/services/sdk/mail';
+
+vi.mock('@/services/sdk/mail', () => ({
+ MailService: { instance: { uploadAttachment: vi.fn() } },
+}));
+
+const uploadAttachment = vi.mocked(MailService.instance.uploadAttachment);
+
+const aFile = (name = 'a.txt') => new File(['x'], name, { type: 'text/plain' });
+
+const flush = async () => {
+ for (let i = 0; i < 10; i++) await Promise.resolve();
+};
+
+describe('Upload Manager', () => {
+ beforeEach(() => {
+ uploadAttachment.mockReset();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ describe('run', () => {
+ test('When files are pushed, then it returns a handle per file with stable id and File', () => {
+ uploadAttachment.mockResolvedValue({ 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 upload success is handled correctly', async () => {
+ const result = { blobId: 'blob-1', name: 'a.txt', size: 1, type: 'text/plain' };
+ uploadAttachment.mockResolvedValue(result);
+ const onSuccess = vi.fn();
+ const onError = vi.fn();
+
+ const [{ id }] = UploadManager.instance.run([aFile()], { onSuccess, onError });
+ await flush();
+
+ expect(onSuccess).toHaveBeenCalledWith(id, result);
+ expect(onError).not.toHaveBeenCalled();
+ });
+
+ test('When upload fails on all attempts, then error is handled properly', async () => {
+ const error = new Error('boom');
+ uploadAttachment.mockRejectedValue(error);
+ const onSuccess = vi.fn();
+ const onError = vi.fn();
+
+ const [{ id }] = UploadManager.instance.run([aFile()], { onSuccess, onError });
+ await flush();
+
+ expect(onError).toHaveBeenCalledWith(id, 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' };
+ uploadAttachment
+ .mockRejectedValueOnce(new Error('1'))
+ .mockRejectedValueOnce(new Error('2'))
+ .mockResolvedValue(result);
+ const onSuccess = vi.fn();
+ const onError = vi.fn();
+
+ UploadManager.instance.run([aFile()], { onSuccess, onError });
+ await flush();
+
+ expect(uploadAttachment).toHaveBeenCalledTimes(3);
+ expect(onSuccess).toHaveBeenCalledTimes(1);
+ expect(onError).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('retry', () => {
+ test('When retry is called with an unknown id, then nothing happens', async () => {
+ const onSuccess = vi.fn();
+ const onError = vi.fn();
+
+ UploadManager.instance.retry('does-not-exist', { onSuccess, onError });
+ await flush();
+
+ expect(uploadAttachment).not.toHaveBeenCalled();
+ expect(onSuccess).not.toHaveBeenCalled();
+ 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;
+ });
+ uploadAttachment.mockImplementationOnce(async () => {
+ await blocker;
+ return { blobId: 'first', name: 'a.txt', 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 });
+
+ UploadManager.instance.remove(second.id);
+ resolveBlocker?.();
+ 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(Object));
+ });
+ });
+});
From 779f75a80a4831cb0c0fba74df46bf4b2078d5f4 Mon Sep 17 00:00:00 2001
From: Xavier Abad <77491413+xabg2@users.noreply.github.com>
Date: Tue, 2 Jun 2026 16:26:24 +0200
Subject: [PATCH 4/8] refactor: improve the remove task flow
---
.../hooks/useAttachments.test.ts | 3 +-
.../compose-message/hooks/useAttachments.ts | 9 ++-
src/components/compose-message/index.tsx | 6 +-
.../components/mail-preview/preview/index.tsx | 2 +-
src/services/sdk/mail/index.ts | 6 +-
src/services/upload-manager/index.ts | 37 +++++++---
.../upload-manager/upload-manager.test.ts | 74 ++++++++++++++++---
7 files changed, 106 insertions(+), 31 deletions(-)
diff --git a/src/components/compose-message/hooks/useAttachments.test.ts b/src/components/compose-message/hooks/useAttachments.test.ts
index fd5a31e..c77cfae 100644
--- a/src/components/compose-message/hooks/useAttachments.test.ts
+++ b/src/components/compose-message/hooks/useAttachments.test.ts
@@ -140,11 +140,12 @@ describe('Attachments - custom hook', () => {
});
describe('clear', () => {
- test('When clear is called, then every attachment is removed from the manager and state', () => {
+ test('When clear is called, then every attachment is removed from the manager and state', async () => {
const { result } = renderHook(() => useAttachments());
act(() => result.current.addFiles([fileOfSize(10, '1.txt'), fileOfSize(20, '2.txt')]));
act(() => result.current.clear());
+ await new Promise((res) => queueMicrotask(res));
expect(remove).toHaveBeenCalledTimes(2);
expect(remove).toHaveBeenCalledWith('id-0');
diff --git a/src/components/compose-message/hooks/useAttachments.ts b/src/components/compose-message/hooks/useAttachments.ts
index cb1612a..ea88b8f 100644
--- a/src/components/compose-message/hooks/useAttachments.ts
+++ b/src/components/compose-message/hooks/useAttachments.ts
@@ -69,9 +69,12 @@ const useAttachments = () => {
}, []);
const clear = useCallback(() => {
- attachments.forEach((a) => UploadManager.instance.remove(a.id));
- setAttachments([]);
- }, [attachments]);
+ setAttachments((prev) => {
+ const ids = prev.map((a) => a.id);
+ queueMicrotask(() => ids.forEach((id) => UploadManager.instance.remove(id)));
+ return [];
+ });
+ }, []);
return { attachments, totalSize, isUploading, hasErrors, addFiles, retry, remove, clear };
};
diff --git a/src/components/compose-message/index.tsx b/src/components/compose-message/index.tsx
index 0e1ea16..35eb48b 100644
--- a/src/components/compose-message/index.tsx
+++ b/src/components/compose-message/index.tsx
@@ -9,7 +9,7 @@ import { EditorBar } from './components/editorBar';
import { ActionDialog, useActionDialog } from '@/context/dialog-manager';
import { useTranslationContext } from '@/i18n';
import useComposeMessage from './hooks/useComposeMessage';
-import useAttachments from './hooks/useAttachments';
+import useAttachments, { type AttachmentTask } from './hooks/useAttachments';
import { useEditor } from '@tiptap/react';
import { EDITOR_CONFIG } from './config';
import {
@@ -112,9 +112,9 @@ export const ComposeMessageDialog = () => {
}
const attachmentsToSend: SendEmailRequest['attachments'] = attachments
- .filter((a) => a.status === 'done' && a.blobId)
+ .filter((a): a is AttachmentTask & { blobId: string } => a.status === 'done' && !!a.blobId)
.map((a) => ({
- blobId: a.blobId as string,
+ blobId: a.blobId,
name: a.name,
size: a.size,
type: a.type,
diff --git a/src/features/mail/components/mail-preview/preview/index.tsx b/src/features/mail/components/mail-preview/preview/index.tsx
index d1cffc5..2a42d13 100644
--- a/src/features/mail/components/mail-preview/preview/index.tsx
+++ b/src/features/mail/components/mail-preview/preview/index.tsx
@@ -57,7 +57,7 @@ const Preview = ({ mailId, subject, body, attachments }: PreviewProps) => {
link.href = url;
link.download = attachment.name;
link.click();
- URL.revokeObjectURL(url);
+ setTimeout(() => URL.revokeObjectURL(url), 0);
} catch {
notificationsService.show({
text: translate('mail.preview.errors.downloadAttachmentFailed'),
diff --git a/src/services/sdk/mail/index.ts b/src/services/sdk/mail/index.ts
index 27ffc31..b7c08ba 100644
--- a/src/services/sdk/mail/index.ts
+++ b/src/services/sdk/mail/index.ts
@@ -16,6 +16,7 @@ import type {
UploadAttachmentResponse,
} from '@internxt/sdk/dist/mail/types';
import { SdkManager } from '..';
+import type { RequestCanceler } from '@internxt/sdk/dist/shared/http/types';
export type MailMeResponse = MailAccountResponse;
@@ -145,7 +146,10 @@ export class MailService {
return this.client.lookupRecipientKeys(addresses);
}
- async uploadAttachment(file: File): Promise {
+ uploadAttachment(file: File): {
+ promise: Promise;
+ requestCanceler: RequestCanceler;
+ } {
return this.client.uploadAttachment(file);
}
diff --git a/src/services/upload-manager/index.ts b/src/services/upload-manager/index.ts
index 4b549fe..d08fb71 100644
--- a/src/services/upload-manager/index.ts
+++ b/src/services/upload-manager/index.ts
@@ -1,6 +1,7 @@
import { queue, type QueueObject } from 'async';
import { MailService } from '@/services/sdk/mail';
import type { UploadAttachmentResponse } from '@internxt/sdk/dist/mail/types';
+import type { RequestCanceler } from '@internxt/sdk/dist/shared/http/client';
export type UploadAttachmentCallbacks = {
onSuccess: (id: string, result: UploadAttachmentResponse) => void;
@@ -17,10 +18,13 @@ type UploadAttachmentTask = {
file: File;
callbacks: UploadAttachmentCallbacks;
cancelled: boolean;
+ failed: boolean;
+ canceler?: RequestCanceler;
};
const UPLOAD_CONCURRENCY = 4;
const MAX_RETRIES = 2;
+const CANCEL_REASON = 'Upload cancelled';
export class UploadManager {
public static readonly instance: UploadManager = new UploadManager();
@@ -32,12 +36,14 @@ export class UploadManager {
this.uploadFiles = queue(async (task) => {
if (task.cancelled) return;
try {
- const result = await this.uploadWithRetries(task.file);
- if (!task.cancelled) task.callbacks.onSuccess(task.id, result);
- } catch (error) {
- if (!task.cancelled) task.callbacks.onError(task.id, error);
- } finally {
+ const result = await this.uploadWithRetries(task);
+ if (task.cancelled) return;
+ task.callbacks.onSuccess(task.id, result);
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);
}
}, UPLOAD_CONCURRENCY);
}
@@ -53,30 +59,41 @@ export class UploadManager {
retry(id: string, callbacks: UploadAttachmentCallbacks): void {
const existing = this.tasks.get(id);
if (!existing) return;
- existing.cancelled = true;
+ this.cancelTask(existing);
this.enqueue(id, existing.file, callbacks);
}
remove(id: string): void {
const task = this.tasks.get(id);
if (!task) return;
- task.cancelled = true;
+ this.cancelTask(task);
this.tasks.delete(id);
}
+ private cancelTask(task: UploadAttachmentTask): void {
+ task.cancelled = true;
+ task.canceler?.cancel(CANCEL_REASON);
+ task.canceler = undefined;
+ }
+
private enqueue(id: string, file: File, callbacks: UploadAttachmentCallbacks): void {
- const task: UploadAttachmentTask = { id, file, callbacks, cancelled: false };
+ const task: UploadAttachmentTask = { id, file, callbacks, cancelled: false, failed: false };
this.tasks.set(id, task);
void this.uploadFiles.push(task);
}
- private async uploadWithRetries(file: File): Promise {
+ 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 MailService.instance.uploadAttachment(file);
+ return await promise;
} catch (error) {
lastError = error;
+ if (task.cancelled) throw error;
+ } finally {
+ task.canceler = undefined;
}
}
throw lastError;
diff --git a/src/services/upload-manager/upload-manager.test.ts b/src/services/upload-manager/upload-manager.test.ts
index fb4eddb..f5239a5 100644
--- a/src/services/upload-manager/upload-manager.test.ts
+++ b/src/services/upload-manager/upload-manager.test.ts
@@ -1,6 +1,7 @@
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';
vi.mock('@/services/sdk/mail', () => ({
MailService: { instance: { uploadAttachment: vi.fn() } },
@@ -14,6 +15,16 @@ const flush = async () => {
for (let i = 0; i < 10; 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() },
+});
+
describe('Upload Manager', () => {
beforeEach(() => {
uploadAttachment.mockReset();
@@ -25,7 +36,7 @@ describe('Upload Manager', () => {
describe('run', () => {
test('When files are pushed, then it returns a handle per file with stable id and File', () => {
- uploadAttachment.mockResolvedValue({ blobId: 'b', name: 'a.txt', size: 1, type: 'text/plain' });
+ uploadAttachment.mockReturnValue(mockSuccess({ blobId: 'b', name: 'a.txt', size: 1, type: 'text/plain' }));
const f1 = aFile('1.txt');
const f2 = aFile('2.txt');
@@ -39,7 +50,7 @@ describe('Upload Manager', () => {
test('When upload succeeds, then the upload success is handled correctly', async () => {
const result = { blobId: 'blob-1', name: 'a.txt', size: 1, type: 'text/plain' };
- uploadAttachment.mockResolvedValue(result);
+ uploadAttachment.mockReturnValue(mockSuccess(result));
const onSuccess = vi.fn();
const onError = vi.fn();
@@ -52,7 +63,7 @@ describe('Upload Manager', () => {
test('When upload fails on all attempts, then error is handled properly', async () => {
const error = new Error('boom');
- uploadAttachment.mockRejectedValue(error);
+ uploadAttachment.mockReturnValue(mockFailure(error));
const onSuccess = vi.fn();
const onError = vi.fn();
@@ -66,9 +77,9 @@ describe('Upload Manager', () => {
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' };
uploadAttachment
- .mockRejectedValueOnce(new Error('1'))
- .mockRejectedValueOnce(new Error('2'))
- .mockResolvedValue(result);
+ .mockReturnValueOnce(mockFailure(new Error('1')))
+ .mockReturnValueOnce(mockFailure(new Error('2')))
+ .mockReturnValue(mockSuccess(result));
const onSuccess = vi.fn();
const onError = vi.fn();
@@ -93,18 +104,35 @@ describe('Upload Manager', () => {
expect(onSuccess).not.toHaveBeenCalled();
expect(onError).not.toHaveBeenCalled();
});
+
+ test('When retry is called after a failed upload, then a new attempt succeeds and notifies onSuccess', 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));
+ const onSuccess = vi.fn();
+ const onError = vi.fn();
+
+ UploadManager.instance.retry(id, { onSuccess, onError });
+ await flush();
+
+ expect(onSuccess).toHaveBeenCalledWith(id, result);
+ 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;
- });
- uploadAttachment.mockImplementationOnce(async () => {
- await blocker;
- return { blobId: 'first', name: 'a.txt', size: 1, type: 'text/plain' };
+ 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' }));
const onSuccess = vi.fn();
const onError = vi.fn();
@@ -118,5 +146,27 @@ describe('Upload Manager', () => {
expect(calls.some(([id]) => id === second.id)).toBe(false);
expect(onSuccess).toHaveBeenCalledWith(first.id, expect.any(Object));
});
+
+ test('When remove is called while the upload is in flight, then the canceler is invoked', async () => {
+ const cancel = vi.fn();
+ let resolveBlocker: (() => void) | undefined;
+ const blocker = new Promise((_res, rej) => {
+ resolveBlocker = () => rej(new Error('aborted'));
+ });
+ uploadAttachment.mockReturnValueOnce({ promise: blocker, requestCanceler: { cancel } });
+
+ const onSuccess = vi.fn();
+ const onError = vi.fn();
+ const [{ id }] = UploadManager.instance.run([aFile()], { onSuccess, onError });
+ await flush();
+
+ UploadManager.instance.remove(id);
+ resolveBlocker?.();
+ await flush();
+
+ expect(cancel).toHaveBeenCalledWith('Upload cancelled');
+ expect(onSuccess).not.toHaveBeenCalled();
+ expect(onError).not.toHaveBeenCalled();
+ });
});
});
From 3f6f0c35966cbcbd75a8ba1ec2563b04361ab700 Mon Sep 17 00:00:00 2001
From: Xavier Abad <77491413+xabg2@users.noreply.github.com>
Date: Wed, 3 Jun 2026 13:20:41 +0200
Subject: [PATCH 5/8] fix: types in custom hook
---
src/components/compose-message/hooks/useAttachments.test.ts | 3 ++-
src/components/compose-message/hooks/useAttachments.ts | 3 ++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/components/compose-message/hooks/useAttachments.test.ts b/src/components/compose-message/hooks/useAttachments.test.ts
index c77cfae..d52e35d 100644
--- a/src/components/compose-message/hooks/useAttachments.test.ts
+++ b/src/components/compose-message/hooks/useAttachments.test.ts
@@ -1,8 +1,9 @@
import { renderHook, act } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import useAttachments, { MAX_TOTAL_ATTACHMENT_BYTES } from './useAttachments';
-import { UploadManager, type UploadAttachmentCallbacks, type UploadHandle } from '@/services/upload-manager';
+import { UploadManager } from '@/services/upload-manager';
import notificationsService, { ToastType } from '@/services/notifications';
+import type { UploadAttachmentCallbacks, UploadHandle } from '@/types/mail/upload-manager';
vi.mock('@/i18n', () => ({ useTranslationContext: () => ({ translate: (key: string) => key }) }));
diff --git a/src/components/compose-message/hooks/useAttachments.ts b/src/components/compose-message/hooks/useAttachments.ts
index ea88b8f..24414a4 100644
--- a/src/components/compose-message/hooks/useAttachments.ts
+++ b/src/components/compose-message/hooks/useAttachments.ts
@@ -1,8 +1,9 @@
import { useCallback, useMemo, useState } from 'react';
import { useTranslationContext } from '@/i18n';
import notificationsService, { ToastType } from '@/services/notifications';
-import { UploadManager, type UploadAttachmentCallbacks } from '@/services/upload-manager';
+import { UploadManager } from '@/services/upload-manager';
import type { AttachmentRef } from '@internxt/sdk/dist/mail/types';
+import type { UploadAttachmentCallbacks } from '@/types/mail/upload-manager';
export type AttachmentStatus = 'uploading' | 'done' | 'error';
From 3b0b2ca054c8fc3422fa4d9c9c5f6f0e014a2086 Mon Sep 17 00:00:00 2001
From: Xavier Abad <77491413+xabg2@users.noreply.github.com>
Date: Wed, 3 Jun 2026 15:57:54 +0200
Subject: [PATCH 6/8] feat: improve attachment too large notification error
---
src/i18n/locales/en.json | 2 +-
src/i18n/locales/es.json | 2 +-
src/i18n/locales/fr.json | 2 +-
src/i18n/locales/it.json | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 1679b5c..f5dd620 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -203,7 +203,7 @@
"noRecipients": "Add at least one recipient before sending",
"sendFailed": "Could not send the email",
"keyLookupFailed": "Could not fetch recipient keys",
- "attachmentsTooLarge": "Attachments exceed the 25 MB limit"
+ "attachmentsTooLarge": "Attachments exceed the {{maxSize}} limit"
},
"attachments": {
"totalSize": "{{used}} of {{max}}"
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index 475a01c..db06cf7 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -205,7 +205,7 @@
"noRecipients": "Agrega al menos un destinatario antes de enviar",
"sendFailed": "No se pudo enviar el correo",
"keyLookupFailed": "No se pudieron obtener las claves del destinatario",
- "attachmentsTooLarge": "Los adjuntos superan el límite de 25 MB"
+ "attachmentsTooLarge": "Los adjuntos superan el límite de {{maxSize}}"
},
"attachments": {
"totalSize": "{{used}} de {{max}}"
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index 90058a6..a54298d 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -205,7 +205,7 @@
"noRecipients": "Ajoutez au moins un destinataire avant d'envoyer",
"sendFailed": "Impossible d'envoyer le courriel",
"keyLookupFailed": "Impossible de récupérer les clés du destinataire",
- "attachmentsTooLarge": "Les pièces jointes dépassent la limite de 25 Mo"
+ "attachmentsTooLarge": "Les pièces jointes dépassent la limite de {{maxSize}}"
},
"attachments": {
"totalSize": "{{used}} sur {{max}}"
diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json
index 83ac6db..4c80896 100644
--- a/src/i18n/locales/it.json
+++ b/src/i18n/locales/it.json
@@ -205,7 +205,7 @@
"noRecipients": "Aggiungi almeno un destinatario prima di inviare",
"sendFailed": "Impossibile inviare l'email",
"keyLookupFailed": "Impossibile recuperare le chiavi del destinatario",
- "attachmentsTooLarge": "Gli allegati superano il limite di 25 MB"
+ "attachmentsTooLarge": "Gli allegati superano il limite di {{maxSize}}"
},
"attachments": {
"totalSize": "{{used}} di {{max}}"
From 605fed843f825d12a6ab45f8d8eb03e5942d9a03 Mon Sep 17 00:00:00 2001
From: Xavier Abad <77491413+xabg2@users.noreply.github.com>
Date: Thu, 4 Jun 2026 09:42:11 +0200
Subject: [PATCH 7/8] fix: import the correct variable
---
src/components/compose-message/components/AttachmentList.tsx | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/components/compose-message/components/AttachmentList.tsx b/src/components/compose-message/components/AttachmentList.tsx
index 8915c74..ef68fea 100644
--- a/src/components/compose-message/components/AttachmentList.tsx
+++ b/src/components/compose-message/components/AttachmentList.tsx
@@ -1,7 +1,8 @@
import { ArrowClockwiseIcon, PaperclipIcon, SpinnerIcon, WarningIcon, XIcon } from '@phosphor-icons/react';
import { bytesToString } from '@/utils/bytes-to-string';
import { useTranslationContext } from '@/i18n';
-import { MAX_TOTAL_ATTACHMENT_BYTES, type AttachmentTask } from '../hooks/useAttachments';
+import { type AttachmentTask } from '../hooks/useAttachments';
+import { MAX_TOTAL_ATTACHMENT_BYTES_PER_MAIL } from '@/constants';
interface AttachmentListProps {
attachments: AttachmentTask[];
@@ -33,7 +34,7 @@ export const AttachmentList = ({ attachments, totalSize, onRemove, onRetry }: At
{translate('modals.composeMessageDialog.attachments.totalSize', {
used: bytesToString({ size: totalSize }),
- max: bytesToString({ size: MAX_TOTAL_ATTACHMENT_BYTES }),
+ max: bytesToString({ size: MAX_TOTAL_ATTACHMENT_BYTES_PER_MAIL }),
})}
From dca282141d0e1c1f16461b9bc40e70481c006378 Mon Sep 17 00:00:00 2001
From: Xavier Abad <77491413+xabg2@users.noreply.github.com>
Date: Thu, 4 Jun 2026 10:07:41 +0200
Subject: [PATCH 8/8] refactor: use compose send custom hook
---
.../hooks/useComposeMessage.ts | 10 ++
.../hooks/useComposeSend.test.ts | 15 +-
.../compose-message/hooks/useComposeSend.ts | 15 ++
src/components/compose-message/index.tsx | 141 +++---------------
4 files changed, 51 insertions(+), 130 deletions(-)
diff --git a/src/components/compose-message/hooks/useComposeMessage.ts b/src/components/compose-message/hooks/useComposeMessage.ts
index 131456b..79009d4 100644
--- a/src/components/compose-message/hooks/useComposeMessage.ts
+++ b/src/components/compose-message/hooks/useComposeMessage.ts
@@ -45,6 +45,15 @@ const useComposeMessage = (draft?: DraftMessage) => {
setBccRecipients((prev) => prev.filter((r) => r.id !== id));
}, []);
+ const clear = () => {
+ setSubjectValue('');
+ setToRecipients([]);
+ setCcRecipients([]);
+ setBccRecipients([]);
+ setShowCc(false);
+ setShowBcc(false);
+ };
+
return {
subjectValue,
toRecipients,
@@ -61,6 +70,7 @@ const useComposeMessage = (draft?: DraftMessage) => {
onRemoveCcRecipient,
onAddBccRecipient,
onRemoveBccRecipient,
+ clear,
};
};
diff --git a/src/components/compose-message/hooks/useComposeSend.test.ts b/src/components/compose-message/hooks/useComposeSend.test.ts
index ac4034b..bcb61a6 100644
--- a/src/components/compose-message/hooks/useComposeSend.test.ts
+++ b/src/components/compose-message/hooks/useComposeSend.test.ts
@@ -41,6 +41,7 @@ const renderSend = (overrides: Partial