From b6d931187c36fd891fcf6fa082068bd9310678ad Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:22:07 +0200 Subject: [PATCH 1/8] feat: attachments --- package-lock.json | 15 ++ package.json | 2 + .../components/AttachmentList.tsx | 41 +++++ .../compose-message/hooks/useAttachments.ts | 81 +++++++++ src/components/compose-message/index.tsx | 160 ++++++++++++++++-- src/features/mail/MailView.tsx | 2 + .../mail/components/mail-preview/index.tsx | 14 +- .../components/mail-preview/preview/index.tsx | 59 ++++++- src/i18n/locales/en.json | 16 +- src/i18n/locales/es.json | 16 +- src/i18n/locales/fr.json | 16 +- src/i18n/locales/it.json | 16 +- src/services/sdk/mail/index.ts | 18 ++ src/services/upload-manager/index.ts | 84 +++++++++ 14 files changed, 511 insertions(+), 29 deletions(-) create mode 100644 src/components/compose-message/components/AttachmentList.tsx create mode 100644 src/components/compose-message/hooks/useAttachments.ts create mode 100644 src/services/upload-manager/index.ts diff --git a/package-lock.json b/package-lock.json index 5390aaa..e1e18c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@tiptap/extension-underline": "^3.20.0", "@tiptap/react": "^3.20.0", "@tiptap/starter-kit": "^3.20.0", + "async": "^3.2.6", "axios": "^1.13.6", "crypto-js": "^4.1.1", "dayjs": "^1.11.20", @@ -52,6 +53,7 @@ "@internxt/prettier-config": "^2.0.1", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", + "@types/async": "^3.2.25", "@types/crypto-js": "^4.2.2", "@types/dompurify": "^3.0.5", "@types/node": "^25.3.3", @@ -4488,6 +4490,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/async": { + "version": "3.2.25", + "resolved": "https://registry.npmjs.org/@types/async/-/async-3.2.25.tgz", + "integrity": "sha512-O6Th/DI18XjrL9TX8LO9F/g26qAz5vynmQqlXt/qLGrskvzCKXKc5/tATz3G2N6lM8eOf3M8/StB14FncAmocg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "dev": true, @@ -5209,6 +5218,12 @@ "node": ">=12" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "license": "MIT" diff --git a/package.json b/package.json index 52c142c..227c4f6 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@tiptap/extension-underline": "^3.20.0", "@tiptap/react": "^3.20.0", "@tiptap/starter-kit": "^3.20.0", + "async": "^3.2.6", "axios": "^1.13.6", "crypto-js": "^4.1.1", "dayjs": "^1.11.20", @@ -65,6 +66,7 @@ "@internxt/prettier-config": "^2.0.1", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", + "@types/async": "^3.2.25", "@types/crypto-js": "^4.2.2", "@types/dompurify": "^3.0.5", "@types/node": "^25.3.3", diff --git a/src/components/compose-message/components/AttachmentList.tsx b/src/components/compose-message/components/AttachmentList.tsx new file mode 100644 index 0000000..aaeb6d2 --- /dev/null +++ b/src/components/compose-message/components/AttachmentList.tsx @@ -0,0 +1,41 @@ +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'; + +interface AttachmentListProps { + attachments: Attachment[]; + totalSize: number; + onRemove: (id: string) => void; + onRetry: (id: string) => void; +} + +export const AttachmentList = ({ attachments, totalSize, onRemove, onRetry }: AttachmentListProps) => { + const { translate } = useTranslationContext(); + if (attachments.length === 0) return null; + return ( +
+
+ {attachments.map((a) => ( +
+ {a.status === 'uploading' && } + {a.status === 'done' && } + {a.status === 'error' && } + {a.name} + {bytesToString({ size: a.size })} + {a.status === 'error' && ( + onRetry(a.id)} /> + )} + onRemove(a.id)} /> +
+ ))} +
+

+ {translate('modals.composeMessageDialog.attachments.totalSize', { + used: bytesToString({ size: totalSize }), + max: bytesToString({ size: MAX_TOTAL_ATTACHMENT_BYTES }), + })} +

+
+ ); +}; diff --git a/src/components/compose-message/hooks/useAttachments.ts b/src/components/compose-message/hooks/useAttachments.ts new file mode 100644 index 0000000..38265f8 --- /dev/null +++ b/src/components/compose-message/hooks/useAttachments.ts @@ -0,0 +1,81 @@ +import { useCallback, useMemo, useState } from 'react'; +import { useTranslationContext } from '@/i18n'; +import notificationsService, { ToastType } from '@/services/notifications'; +import { UploadManager, type UploadAttachmentCallbacks } from '@/services/upload-manager'; + +export type AttachmentStatus = 'uploading' | 'done' | 'error'; + +export type Attachment = { + 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 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 callbacks: UploadAttachmentCallbacks = useMemo( + () => ({ + onSuccess: (id, { blobId }) => + setAttachments((prev) => prev.map((a) => (a.id === id ? { ...a, status: 'done', blobId } : a))), + onError: (id) => setAttachments((prev) => prev.map((a) => (a.id === id ? { ...a, status: 'error' } : a))), + }), + [], + ); + + const addFiles = useCallback( + (files: FileList | File[]) => { + const list = Array.from(files); + const incoming = list.reduce((s, f) => s + f.size, 0); + if (totalSize + incoming > MAX_TOTAL_ATTACHMENT_BYTES) { + notificationsService.show({ + text: translate('modals.composeMessageDialog.errors.attachmentsTooLarge'), + type: ToastType.Warning, + }); + return; + } + const handles = UploadManager.instance.run(list, callbacks); + const pending: Attachment[] = handles.map(({ id, file }) => ({ + id, + name: file.name, + size: file.size, + mimeType: file.type || 'application/octet-stream', + status: 'uploading', + })); + setAttachments((prev) => [...prev, ...pending]); + }, + [totalSize, translate, callbacks], + ); + + const retry = useCallback( + (id: string) => { + setAttachments((prev) => prev.map((a) => (a.id === id ? { ...a, status: 'uploading' } : a))); + UploadManager.instance.retry(id, callbacks); + }, + [callbacks], + ); + + const remove = useCallback((id: string) => { + UploadManager.instance.remove(id); + setAttachments((prev) => prev.filter((a) => a.id !== id)); + }, []); + + const clear = useCallback(() => { + attachments.forEach((a) => UploadManager.instance.remove(a.id)); + setAttachments([]); + }, [attachments]); + + return { attachments, totalSize, isUploading, hasErrors, addFiles, retry, remove, clear }; +}; + +export default useAttachments; diff --git a/src/components/compose-message/index.tsx b/src/components/compose-message/index.tsx index 70f5311..c53fcb0 100644 --- a/src/components/compose-message/index.tsx +++ b/src/components/compose-message/index.tsx @@ -1,16 +1,27 @@ import { LockKeyIcon, PaperclipIcon, WarningIcon, XIcon } from '@phosphor-icons/react'; -import { useCallback } from 'react'; +import { useCallback, useMemo, useRef, type ChangeEvent } from 'react'; import type { Recipient } from './types'; import { RecipientInput } from './components/RecipientInput'; +import { AttachmentList } from './components/AttachmentList'; import { Button, Input } from '@internxt/ui'; import RichTextEditor from './components/RichTextEditor'; import { EditorBar } from './components/editorBar'; import { ActionDialog, useActionDialog } from '@/context/dialog-manager'; import { useTranslationContext } from '@/i18n'; import useComposeMessage from './hooks/useComposeMessage'; -import useComposeSend from './hooks/useComposeSend'; +import useAttachments from './hooks/useAttachments'; import { useEditor } from '@tiptap/react'; import { EDITOR_CONFIG } from './config'; +import { + useGetActiveDomainsQuery, + useGetMailAccountKeysQuery, + useLazyLookupRecipientKeysQuery, + useSendEmailMutation, +} from '@/store/api/mail'; +import { classifyRecipients, uniqueEmailAddresses } from '@/utils/domain'; +import { MailEncryptionService, type RecipientPublicKey } from '@/services/mail-encryption'; +import notificationsService, { ToastType } from '@/services/notifications'; +import type { EmailAddress, SendEmailRequest } from '@internxt/sdk/dist/mail/types'; export interface DraftMessage { subject?: string; @@ -20,6 +31,8 @@ export interface DraftMessage { body?: string; } +const toEmailAddress = (r: Recipient): EmailAddress => (r.name ? { name: r.name, email: r.email } : { email: r.email }); + export const ComposeMessageDialog = () => { const { translate } = useTranslationContext(); const { closeDialog: onComposeMessageDialogClose, getDialogData: getComposeMessageDialogData } = useActionDialog(); @@ -46,18 +59,132 @@ export const ComposeMessageDialog = () => { const title = draft.subject ?? translate('modals.composeMessageDialog.title'); const editor = useEditor(EDITOR_CONFIG); + const { data: activeDomains } = useGetActiveDomainsQuery(); + const { data: senderKeys } = useGetMailAccountKeysQuery(); + const [triggerLookup] = useLazyLookupRecipientKeysQuery(); + const [sendEmail, { isLoading: isSending }] = useSendEmailMutation(); + + const { + attachments, + totalSize: attachmentsTotalSize, + isUploading: isUploadingAttachments, + hasErrors: hasAttachmentErrors, + addFiles: addAttachmentFiles, + retry: retryAttachment, + remove: removeAttachment, + clear: clearAttachments, + } = useAttachments(); + const fileInputRef = useRef(null); + + const onFilesPicked = (e: ChangeEvent) => { + if (e.target.files?.length) addAttachmentFiles(e.target.files); + e.target.value = ''; + }; + + const allRecipients = useMemo( + () => [...toRecipients, ...ccRecipients, ...bccRecipients], + [toRecipients, ccRecipients, bccRecipients], + ); + + const encryptionState = useMemo<'none' | 'encrypted' | 'cleartext'>(() => { + if (allRecipients.length === 0) return 'none'; + if (!activeDomains) return 'none'; + return classifyRecipients( + allRecipients.map((r) => r.email), + activeDomains, + ).allInternxt + ? 'encrypted' + : 'cleartext'; + }, [allRecipients, activeDomains]); + const onClose = useCallback(() => { + clearAttachments(); onComposeMessageDialogClose(ActionDialog.ComposeMessage); - }, [onComposeMessageDialogClose]); + }, [onComposeMessageDialogClose, clearAttachments]); - const { encryptionState, isSending, send } = useComposeSend({ + const handlePrimaryAction = useCallback(async () => { + if (allRecipients.length === 0) { + notificationsService.show({ + text: translate('modals.composeMessageDialog.errors.noRecipients'), + type: ToastType.Warning, + }); + return; + } + + const attachmentsToSend: SendEmailRequest['attachments'] = attachments + .filter((a) => a.status === 'done' && a.blobId) + .map((a) => ({ + blobId: a.blobId as string, + name: a.name, + size: a.size, + type: a.mimeType, + })); + + const htmlBody = editor?.getHTML() ?? ''; + const textBody = editor?.getText() ?? ''; + const cleartextPayload: SendEmailRequest = { + to: toRecipients.map(toEmailAddress), + cc: ccRecipients.length ? ccRecipients.map(toEmailAddress) : undefined, + bcc: bccRecipients.length ? bccRecipients.map(toEmailAddress) : undefined, + subject: subjectValue, + textBody: textBody || undefined, + htmlBody: htmlBody || undefined, + attachments: attachmentsToSend, + }; + + try { + if (encryptionState === 'encrypted' && senderKeys?.address && senderKeys.publicKey) { + const uniqueAddresses = uniqueEmailAddresses(allRecipients.map((r) => r.email)); + const lookup = await triggerLookup({ addresses: uniqueAddresses }).unwrap(); + const usable = lookup.filter((r): r is { address: string; publicKey: string } => Boolean(r.publicKey)); + + if (usable.length === uniqueAddresses.length) { + const recipientsWithKeys: RecipientPublicKey[] = [ + ...usable, + { address: senderKeys.address, publicKey: senderKeys.publicKey }, + ]; + const encryption = await MailEncryptionService.instance.buildEncryptionBlock( + { body: htmlBody || textBody, previewText: textBody }, + recipientsWithKeys, + ); + + await sendEmail({ + to: toRecipients.map(toEmailAddress), + cc: ccRecipients.length ? ccRecipients.map(toEmailAddress) : undefined, + bcc: bccRecipients.length ? bccRecipients.map(toEmailAddress) : undefined, + subject: subjectValue, + encryption, + attachments: attachmentsToSend, + }).unwrap(); + } else { + await sendEmail(cleartextPayload).unwrap(); + } + } else { + await sendEmail(cleartextPayload).unwrap(); + } + onClose(); + } catch (error) { + console.error('[SEND EMAIL] Error while sending an email: ', error); + notificationsService.show({ + text: translate('modals.composeMessageDialog.errors.sendFailed'), + type: ToastType.Error, + }); + } + }, [ + attachments, + allRecipients, + editor, toRecipients, ccRecipients, bccRecipients, - subject: subjectValue, - editor, - onSent: onClose, - }); + subjectValue, + encryptionState, + senderKeys, + triggerLookup, + sendEmail, + onClose, + translate, + ]); if (!editor) return null; @@ -136,7 +263,13 @@ export const ComposeMessageDialog = () => {
- {/* !TODO: Handle attachments */} + +
{encryptionState === 'encrypted' && ( @@ -157,10 +290,15 @@ export const ComposeMessageDialog = () => { {translate('modals.composeMessageDialog.cleartextBadge')} )} - -
diff --git a/src/features/mail/MailView.tsx b/src/features/mail/MailView.tsx index cdc5907..bdf0994 100644 --- a/src/features/mail/MailView.tsx +++ b/src/features/mail/MailView.tsx @@ -149,9 +149,11 @@ const MailView = ({ folder }: MailViewProps) => { cc={cc.map((u) => ({ name: u.name ?? '', email: u.email }))} bcc={bcc.map((u) => ({ name: u.name ?? '', email: u.email }))} mail={{ + id: activeMail.id, subject: decrypted.subject || activeMail.subject, receivedAt: activeMail.receivedAt, htmlBody: decrypted.htmlBody, + attachments: activeMail.attachments, isEncrypted: decrypted.isEncrypted, isDecrypting: decrypted.isDecrypting, decryptError: decrypted.decryptError, diff --git a/src/features/mail/components/mail-preview/index.tsx b/src/features/mail/components/mail-preview/index.tsx index b076f06..d7101cc 100644 --- a/src/features/mail/components/mail-preview/index.tsx +++ b/src/features/mail/components/mail-preview/index.tsx @@ -2,6 +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'; interface PreviewMailProps { from: User; @@ -9,9 +10,11 @@ interface PreviewMailProps { cc: User[]; bcc: User[]; mail: { + id: string; subject: string; receivedAt: string; htmlBody: string; + attachments?: EmailResponse['attachments']; isEncrypted?: boolean; isDecrypting?: boolean; decryptError?: boolean; @@ -23,7 +26,14 @@ const PreviewMail = ({ from, to, cc, bcc, mail }: PreviewMailProps) => { return (
- + {mail.isEncrypted && !mail.decryptError && (
@@ -39,7 +49,7 @@ 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 6326a55..d1cffc5 100644 --- a/src/features/mail/components/mail-preview/preview/index.tsx +++ b/src/features/mail/components/mail-preview/preview/index.tsx @@ -1,4 +1,10 @@ +import type { EmailResponse } 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'; const purify = DOMPurify(); @@ -24,15 +30,42 @@ purify.addHook('afterSanitizeAttributes', (node) => { } }); +type EmailAttachment = NonNullable[number]; + interface PreviewProps { + mailId: string; subject: string; body: string; - attachments?: string[]; + attachments?: EmailResponse['attachments']; } -const Preview = ({ subject, body, attachments }: PreviewProps) => { +const Preview = ({ mailId, subject, body, attachments }: PreviewProps) => { + const { translate } = useTranslationContext(); const sanitizedBody = purify.sanitize(body); + 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 url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = attachment.name; + link.click(); + URL.revokeObjectURL(url); + } catch { + notificationsService.show({ + text: translate('mail.preview.errors.downloadAttachmentFailed'), + type: ToastType.Error, + }); + } + }; + return (
@@ -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[0]> = { bccRecipients: [], subject: 'Hi', editor, + attachments: [], onSent, ...overrides, }), @@ -172,14 +173,12 @@ describe('useComposeSend', () => { mocks.triggerLookup.mockReturnValue({ unwrap: () => Promise.resolve([{ address: 'bob@inxt.me', publicKey: 'bob-pk' }]), }); - const buildSpy = vi - .spyOn(MailEncryptionService.instance, 'buildEncryptionBlock') - .mockResolvedValue({ - version: 'v1', - encryptedText: 'ct', - encryptedPreview: 'cp', - wrappedKeys: [], - } as EncryptionBlock); + const buildSpy = vi.spyOn(MailEncryptionService.instance, 'buildEncryptionBlock').mockResolvedValue({ + version: 'v1', + encryptedText: 'ct', + encryptedPreview: 'cp', + wrappedKeys: [], + } as EncryptionBlock); const { result, onSent } = renderSend({ toRecipients: [recipient('bob@inxt.me')] }); diff --git a/src/components/compose-message/hooks/useComposeSend.ts b/src/components/compose-message/hooks/useComposeSend.ts index c80d055..d567c87 100644 --- a/src/components/compose-message/hooks/useComposeSend.ts +++ b/src/components/compose-message/hooks/useComposeSend.ts @@ -12,6 +12,7 @@ import { MailEncryptionService, type RecipientPublicKey } from '@/services/mail- import notificationsService, { ToastType } from '@/services/notifications'; import { useTranslationContext } from '@/i18n'; import type { Recipient } from '../types'; +import type { AttachmentTask } from './useAttachments'; export type EncryptionState = 'none' | 'unknown' | 'encrypted' | 'cleartext'; @@ -23,6 +24,7 @@ interface UseComposeSendParams { bccRecipients: Recipient[]; subject: string; editor: Editor | null; + attachments: AttachmentTask[]; onSent: () => void; } @@ -43,6 +45,7 @@ export const useComposeSend = ({ bccRecipients, subject, editor, + attachments, onSent, }: UseComposeSendParams): UseComposeSendResult => { const { translate } = useTranslationContext(); @@ -85,6 +88,15 @@ export const useComposeSend = ({ return; } + const attachmentsToSend: SendEmailRequest['attachments'] = attachments + .filter((a): a is AttachmentTask & { blobId: string } => a.status === 'done' && !!a.blobId) + .map((a) => ({ + blobId: a.blobId, + name: a.name, + size: a.size, + type: a.type, + })); + const htmlBody = editor?.getHTML() ?? ''; const textBody = editor?.getText() ?? ''; const cleartextPayload: SendEmailRequest = { @@ -94,6 +106,7 @@ export const useComposeSend = ({ subject, textBody: textBody || undefined, htmlBody: htmlBody || undefined, + attachments: attachmentsToSend, }; try { @@ -140,6 +153,7 @@ export const useComposeSend = ({ bcc: bccRecipients.length ? bccRecipients.map(toEmailAddress) : undefined, subject, encryption, + attachments: attachmentsToSend, }).unwrap(); } else { await sendEmail(cleartextPayload).unwrap(); @@ -160,6 +174,7 @@ export const useComposeSend = ({ subject, encryptionState, senderKeys, + attachments, triggerLookup, sendEmail, onSent, diff --git a/src/components/compose-message/index.tsx b/src/components/compose-message/index.tsx index 35eb48b..e971d24 100644 --- a/src/components/compose-message/index.tsx +++ b/src/components/compose-message/index.tsx @@ -1,5 +1,5 @@ import { LockKeyIcon, PaperclipIcon, WarningIcon, XIcon } from '@phosphor-icons/react'; -import { useCallback, useMemo, useRef, type ChangeEvent } from 'react'; +import { useCallback, useRef, type ChangeEvent } from 'react'; import type { Recipient } from './types'; import { RecipientInput } from './components/RecipientInput'; import { AttachmentList } from './components/AttachmentList'; @@ -9,19 +9,10 @@ import { EditorBar } from './components/editorBar'; import { ActionDialog, useActionDialog } from '@/context/dialog-manager'; import { useTranslationContext } from '@/i18n'; import useComposeMessage from './hooks/useComposeMessage'; -import useAttachments, { type AttachmentTask } from './hooks/useAttachments'; +import useAttachments from './hooks/useAttachments'; import { useEditor } from '@tiptap/react'; import { EDITOR_CONFIG } from './config'; -import { - useGetActiveDomainsQuery, - useGetMailAccountKeysQuery, - useLazyLookupRecipientKeysQuery, - useSendEmailMutation, -} from '@/store/api/mail'; -import { classifyRecipients, uniqueEmailAddresses } from '@/utils/domain'; -import { MailEncryptionService, type RecipientPublicKey } from '@/services/mail-encryption'; -import notificationsService, { ToastType } from '@/services/notifications'; -import type { EmailAddress, SendEmailRequest } from '@internxt/sdk/dist/mail/types'; +import useComposeSend from './hooks/useComposeSend'; export interface DraftMessage { subject?: string; @@ -31,8 +22,6 @@ export interface DraftMessage { body?: string; } -const toEmailAddress = (r: Recipient): EmailAddress => (r.name ? { name: r.name, email: r.email } : { email: r.email }); - export const ComposeMessageDialog = () => { const { translate } = useTranslationContext(); const { closeDialog: onComposeMessageDialogClose, getDialogData: getComposeMessageDialogData } = useActionDialog(); @@ -54,16 +43,12 @@ export const ComposeMessageDialog = () => { onShowBccRecipient, onShowCcRecipient, onSubjectChange, + clear: clearComposeMessage, } = useComposeMessage(); const title = draft.subject ?? translate('modals.composeMessageDialog.title'); const editor = useEditor(EDITOR_CONFIG); - const { data: activeDomains } = useGetActiveDomainsQuery(); - const { data: senderKeys } = useGetMailAccountKeysQuery(); - const [triggerLookup] = useLazyLookupRecipientKeysQuery(); - const [sendEmail, { isLoading: isSending }] = useSendEmailMutation(); - const { attachments, totalSize: attachmentsTotalSize, @@ -76,115 +61,27 @@ export const ComposeMessageDialog = () => { } = useAttachments(); const fileInputRef = useRef(null); - const onFilesPicked = (e: ChangeEvent) => { - if (e.target.files?.length) addAttachmentFiles(e.target.files); - e.target.value = ''; - }; - - const allRecipients = useMemo( - () => [...toRecipients, ...ccRecipients, ...bccRecipients], - [toRecipients, ccRecipients, bccRecipients], - ); - - const encryptionState = useMemo<'none' | 'encrypted' | 'cleartext'>(() => { - if (allRecipients.length === 0) return 'none'; - if (!activeDomains) return 'none'; - return classifyRecipients( - allRecipients.map((r) => r.email), - activeDomains, - ).allInternxt - ? 'encrypted' - : 'cleartext'; - }, [allRecipients, activeDomains]); - const onClose = useCallback(() => { clearAttachments(); + clearComposeMessage(); + editor.commands.clearContent(); onComposeMessageDialogClose(ActionDialog.ComposeMessage); - }, [onComposeMessageDialogClose, clearAttachments]); - - const handlePrimaryAction = useCallback(async () => { - if (allRecipients.length === 0) { - notificationsService.show({ - text: translate('modals.composeMessageDialog.errors.noRecipients'), - type: ToastType.Warning, - }); - return; - } - - const attachmentsToSend: SendEmailRequest['attachments'] = attachments - .filter((a): a is AttachmentTask & { blobId: string } => a.status === 'done' && !!a.blobId) - .map((a) => ({ - blobId: a.blobId, - name: a.name, - size: a.size, - type: a.type, - })); - - const htmlBody = editor?.getHTML() ?? ''; - const textBody = editor?.getText() ?? ''; - const cleartextPayload: SendEmailRequest = { - to: toRecipients.map(toEmailAddress), - cc: ccRecipients.length ? ccRecipients.map(toEmailAddress) : undefined, - bcc: bccRecipients.length ? bccRecipients.map(toEmailAddress) : undefined, - subject: subjectValue, - textBody: textBody || undefined, - htmlBody: htmlBody || undefined, - attachments: attachmentsToSend, - }; + }, [editor, clearComposeMessage, onComposeMessageDialogClose, clearAttachments]); - try { - if (encryptionState === 'encrypted' && senderKeys?.address && senderKeys.publicKey) { - const uniqueAddresses = uniqueEmailAddresses(allRecipients.map((r) => r.email)); - const lookup = await triggerLookup({ addresses: uniqueAddresses }).unwrap(); - const usable = lookup.filter((r): r is { address: string; publicKey: string } => Boolean(r.publicKey)); - - if (usable.length === uniqueAddresses.length) { - const recipientsWithKeys: RecipientPublicKey[] = [ - ...usable, - { address: senderKeys.address, publicKey: senderKeys.publicKey }, - ]; - const encryption = await MailEncryptionService.instance.buildEncryptionBlock( - { body: htmlBody || textBody, previewText: textBody }, - recipientsWithKeys, - ); - - await sendEmail({ - to: toRecipients.map(toEmailAddress), - cc: ccRecipients.length ? ccRecipients.map(toEmailAddress) : undefined, - bcc: bccRecipients.length ? bccRecipients.map(toEmailAddress) : undefined, - subject: subjectValue, - encryption, - attachments: attachmentsToSend, - }).unwrap(); - } else { - await sendEmail(cleartextPayload).unwrap(); - } - } else { - await sendEmail(cleartextPayload).unwrap(); - } - onClose(); - } catch (error) { - console.error('[SEND EMAIL] Error while sending an email: ', error); - notificationsService.show({ - text: translate('modals.composeMessageDialog.errors.sendFailed'), - type: ToastType.Error, - }); - } - }, [ + const { send, encryptionState, isSending } = useComposeSend({ attachments, - allRecipients, + bccRecipients, + ccRecipients, editor, + subject: subjectValue, toRecipients, - ccRecipients, - bccRecipients, - subjectValue, - encryptionState, - senderKeys, - triggerLookup, - sendEmail, - onClose, - translate, - ]); + onSent: onClose, + }); + + const onFilesPicked = (e: ChangeEvent) => { + if (e.target.files?.length) addAttachmentFiles(e.target.files); + e.target.value = ''; + }; if (!editor) return null; @@ -294,7 +191,7 @@ export const ComposeMessageDialog = () => {