diff --git a/package-lock.json b/package-lock.json index 04d00e4..5390aaa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@internxt/css-config": "^1.1.0", "@internxt/lib": "^1.4.1", - "@internxt/sdk": "^1.16.3", + "@internxt/sdk": "1.17.2", "@internxt/ui": "^0.1.16", "@phosphor-icons/react": "^2.1.10", "@reduxjs/toolkit": "^2.11.2", @@ -31,7 +31,7 @@ "dompurify": "^3.3.3", "i18next": "^25.8.13", "idb": "^8.0.3", - "internxt-crypto": "^1.3.0", + "internxt-crypto": "1.4.0", "prettysize": "^2.0.0", "react": "^19.2.0", "react-device-detect": "^2.2.3", @@ -1453,9 +1453,9 @@ "license": "MIT" }, "node_modules/@internxt/sdk": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/@internxt/sdk/-/sdk-1.16.3.tgz", - "integrity": "sha512-GmX9eYBOBB09wr5e9yW3gUIR3Pn2AgZBXzZd1HvzwS96AonclcEWY1/+uoZ9qLO4SdvYquiOjXfLYNJGg99ugQ==", + "version": "1.17.2", + "resolved": "https://registry.npmjs.org/@internxt/sdk/-/sdk-1.17.2.tgz", + "integrity": "sha512-Wrtacs42uj8It4jq8cMZsSYyNME6swp7B6n0k+WMOGSfh5EkwaaOGtILmlZpLUKl3xEUBJWSSwxkR/qHo4FmLQ==", "license": "MIT", "dependencies": { "axios": "^1.16.0" @@ -1524,9 +1524,9 @@ } }, "node_modules/@noble/ciphers": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", - "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -1536,12 +1536,12 @@ } }, "node_modules/@noble/curves": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", - "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", "license": "MIT", "dependencies": { - "@noble/hashes": "2.0.1" + "@noble/hashes": "2.2.0" }, "engines": { "node": ">= 20.19.0" @@ -1551,7 +1551,9 @@ } }, "node_modules/@noble/hashes": { - "version": "2.0.1", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -1561,13 +1563,14 @@ } }, "node_modules/@noble/post-quantum": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.5.4.tgz", - "integrity": "sha512-leww0zzIirrvwaYMPI9fj6aRIlA/c6Y0/lifQQ1YOOyHEr0MNH3yYpjXeiVG+tWdPps4XxGclFWX2INPO3Yo5w==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.6.1.tgz", + "integrity": "sha512-+pormrDZwjRw05U8ADK4JpHejo87+gBd+muRBB/ozztH5yhDLMDF4jHQWN3NQQAsu1zBNPWTG0ZwVI0CR29H0A==", "license": "MIT", "dependencies": { - "@noble/curves": "~2.0.0", - "@noble/hashes": "~2.0.0" + "@noble/ciphers": "~2.2.0", + "@noble/curves": "~2.2.0", + "@noble/hashes": "~2.2.0" }, "engines": { "node": ">= 20.19.0" @@ -3536,22 +3539,22 @@ ] }, "node_modules/@scure/base": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", - "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", "license": "MIT", "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@scure/bip39": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", - "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz", + "integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==", "license": "MIT", "dependencies": { - "@noble/hashes": "2.0.1", - "@scure/base": "2.0.0" + "@noble/hashes": "2.2.0", + "@scure/base": "2.2.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -7068,18 +7071,18 @@ "license": "ISC" }, "node_modules/internxt-crypto": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/internxt-crypto/-/internxt-crypto-1.3.0.tgz", - "integrity": "sha512-OEZzwtq+PkPQ8WmMwfqv6+e5iFn77avB6SM/5fV9YgZ6RU5t8R8yqKnXlbi8SKq7VqnxBUwRnh2zPNtky94R3w==", - "dependencies": { - "@noble/ciphers": "^2.1.1", - "@noble/curves": "^2.0.1", - "@noble/hashes": "^2.0.1", - "@noble/post-quantum": "^0.5.2", - "@scure/bip39": "^2.0.1", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/internxt-crypto/-/internxt-crypto-1.4.0.tgz", + "integrity": "sha512-4G7M1CBhnkLBbIfMhXpCJA9mTezZfJnleTgRM7Z0iicCXdjlGzBfygRJwlnnhKNvh45pu9hIQ+M5dA0jVIIKzw==", + "dependencies": { + "@noble/ciphers": "^2.2.0", + "@noble/curves": "^2.2.0", + "@noble/hashes": "^2.2.0", + "@noble/post-quantum": "^0.6.1", + "@scure/bip39": "^2.2.0", "hash-wasm": "^4.12.0", "husky": "^9.1.7", - "uuid": "^13.0.0" + "uuid": "^14.0.0" } }, "node_modules/is-arguments": { @@ -9841,9 +9844,9 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" diff --git a/package.json b/package.json index 19cf4a8..52c142c 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "dependencies": { "@internxt/css-config": "^1.1.0", "@internxt/lib": "^1.4.1", - "@internxt/sdk": "^1.16.3", + "@internxt/sdk": "1.17.2", "@internxt/ui": "^0.1.16", "@phosphor-icons/react": "^2.1.10", "@reduxjs/toolkit": "^2.11.2", @@ -44,7 +44,7 @@ "dompurify": "^3.3.3", "i18next": "^25.8.13", "idb": "^8.0.3", - "internxt-crypto": "^1.3.0", + "internxt-crypto": "1.4.0", "prettysize": "^2.0.0", "react": "^19.2.0", "react-device-detect": "^2.2.3", diff --git a/src/components/compose-message/hooks/useComposeSend.test.ts b/src/components/compose-message/hooks/useComposeSend.test.ts new file mode 100644 index 0000000..ac4034b --- /dev/null +++ b/src/components/compose-message/hooks/useComposeSend.test.ts @@ -0,0 +1,204 @@ +import { renderHook, act } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { Editor } from '@tiptap/react'; +import type { EncryptionBlock } from '@internxt/sdk/dist/mail/types'; +import useComposeSend from './useComposeSend'; +import { MailEncryptionService } from '@/services/mail-encryption'; +import notificationsService from '@/services/notifications'; +import type { Recipient } from '../types'; + +const mocks = vi.hoisted(() => ({ + activeDomains: undefined as { domain: string }[] | undefined, + senderKeys: undefined as { address: string; publicKey: string } | undefined, + triggerLookup: vi.fn(), + sendEmail: vi.fn(), +})); + +vi.mock('@/store/api/mail', () => ({ + useGetActiveDomainsQuery: () => ({ data: mocks.activeDomains }), + useGetMailAccountKeysQuery: () => ({ data: mocks.senderKeys }), + useLazyLookupRecipientKeysQuery: () => [mocks.triggerLookup], + useSendEmailMutation: () => [mocks.sendEmail, { isLoading: false }], +})); + +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' }, +})); + +const editor = { getHTML: () => '

body

', getText: () => 'body' } as unknown as Editor; +const recipient = (email: string): Recipient => ({ id: email, email }); +const show = vi.mocked(notificationsService.show); + +const renderSend = (overrides: Partial[0]> = {}) => { + const onSent = vi.fn(); + const { result } = renderHook(() => + useComposeSend({ + toRecipients: [], + ccRecipients: [], + bccRecipients: [], + subject: 'Hi', + editor, + onSent, + ...overrides, + }), + ); + return { result, onSent }; +}; + +describe('useComposeSend', () => { + beforeEach(() => { + vi.restoreAllMocks(); + mocks.triggerLookup.mockReset(); + mocks.sendEmail.mockReset(); + show.mockReset(); + + mocks.activeDomains = [{ domain: 'inxt.me' }]; + mocks.senderKeys = { address: 'me@inxt.me', publicKey: 'sender-pk' }; + mocks.triggerLookup.mockReturnValue({ unwrap: () => Promise.resolve([]) }); + mocks.sendEmail.mockReturnValue({ unwrap: () => Promise.resolve({ id: 'mail-1' }) }); + }); + + test('When there are no recipients, then it warns and does not send', async () => { + const { result, onSent } = renderSend(); + + await act(async () => { + await result.current.send(); + }); + + expect(show).toHaveBeenCalledWith(expect.objectContaining({ text: 'errors.mail.noRecipients' })); + expect(mocks.sendEmail).not.toHaveBeenCalled(); + expect(onSent).not.toHaveBeenCalled(); + }); + + test('When the active domains have not resolved, then the send is blocked to avoid a cleartext downgrade', async () => { + mocks.activeDomains = undefined; + + const { result, onSent } = renderSend({ toRecipients: [recipient('bob@inxt.me')] }); + + expect(result.current.encryptionState).toBe('unknown'); + + await act(async () => { + await result.current.send(); + }); + + expect(show).toHaveBeenCalledWith(expect.objectContaining({ text: 'errors.mail.encryptionUnavailable' })); + expect(mocks.sendEmail).not.toHaveBeenCalled(); + expect(onSent).not.toHaveBeenCalled(); + }); + + test('When sending encrypted but the sender keys are missing, then it reports a key lookup failure', async () => { + mocks.senderKeys = undefined; + + const { result, onSent } = renderSend({ toRecipients: [recipient('bob@inxt.me')] }); + + await act(async () => { + await result.current.send(); + }); + + expect(show).toHaveBeenCalledWith(expect.objectContaining({ text: 'errors.mail.keyLookupFailed' })); + expect(mocks.sendEmail).not.toHaveBeenCalled(); + expect(onSent).not.toHaveBeenCalled(); + }); + + test('When the recipient key lookup throws, then it reports a key lookup failure rather than a send failure', async () => { + mocks.triggerLookup.mockReturnValue({ unwrap: () => Promise.reject(new Error('network')) }); + + const { result, onSent } = renderSend({ toRecipients: [recipient('bob@inxt.me')] }); + + await act(async () => { + await result.current.send(); + }); + + expect(show).toHaveBeenCalledWith(expect.objectContaining({ text: 'errors.mail.keyLookupFailed' })); + expect(mocks.sendEmail).not.toHaveBeenCalled(); + expect(onSent).not.toHaveBeenCalled(); + }); + + test('When some recipients have no key, then it reports a key lookup failure', async () => { + mocks.triggerLookup.mockReturnValue({ + unwrap: () => Promise.resolve([{ address: 'bob@inxt.me', publicKey: null }]), + }); + + const { result, onSent } = renderSend({ toRecipients: [recipient('bob@inxt.me')] }); + + await act(async () => { + await result.current.send(); + }); + + expect(show).toHaveBeenCalledWith(expect.objectContaining({ text: 'errors.mail.keyLookupFailed' })); + expect(mocks.sendEmail).not.toHaveBeenCalled(); + expect(onSent).not.toHaveBeenCalled(); + }); + + test('When the send mutation fails, then it reports a send failure and does not close the dialog', async () => { + mocks.sendEmail.mockReturnValue({ unwrap: () => Promise.reject(new Error('boom')) }); + + const { result, onSent } = renderSend({ toRecipients: [recipient('bob@gmail.com')] }); + + expect(result.current.encryptionState).toBe('cleartext'); + + await act(async () => { + await result.current.send(); + }); + + expect(show).toHaveBeenCalledWith(expect.objectContaining({ text: 'errors.mail.sendFailed' })); + expect(onSent).not.toHaveBeenCalled(); + }); + + test('When all recipients are external, then it sends cleartext and closes the dialog', async () => { + const { result, onSent } = renderSend({ toRecipients: [recipient('bob@gmail.com')] }); + + expect(result.current.encryptionState).toBe('cleartext'); + + await act(async () => { + await result.current.send(); + }); + + expect(mocks.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: [{ email: 'bob@gmail.com' }], + subject: 'Hi', + htmlBody: '

body

', + textBody: 'body', + }), + ); + expect(onSent).toHaveBeenCalled(); + }); + + test('When all recipients are Internxt, then it encrypts the body and sends with the sender included', async () => { + 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 { result, onSent } = renderSend({ toRecipients: [recipient('bob@inxt.me')] }); + + expect(result.current.encryptionState).toBe('encrypted'); + + await act(async () => { + await result.current.send(); + }); + + expect(buildSpy).toHaveBeenCalledWith( + { body: '

body

', previewText: 'body' }, + expect.arrayContaining([ + { address: 'bob@inxt.me', publicKey: 'bob-pk' }, + { address: 'me@inxt.me', publicKey: 'sender-pk' }, + ]), + ); + expect(mocks.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ subject: 'Hi', encryption: expect.objectContaining({ version: 'v1' }) }), + ); + expect(onSent).toHaveBeenCalled(); + }); +}); diff --git a/src/components/compose-message/hooks/useComposeSend.ts b/src/components/compose-message/hooks/useComposeSend.ts new file mode 100644 index 0000000..c80d055 --- /dev/null +++ b/src/components/compose-message/hooks/useComposeSend.ts @@ -0,0 +1,172 @@ +import { useCallback, useMemo } from 'react'; +import type { Editor } from '@tiptap/react'; +import type { EmailAddress, RecipientKey, SendEmailRequest } from '@internxt/sdk/dist/mail/types'; +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 { useTranslationContext } from '@/i18n'; +import type { Recipient } from '../types'; + +export type EncryptionState = 'none' | 'unknown' | 'encrypted' | 'cleartext'; + +const toEmailAddress = (r: Recipient): EmailAddress => (r.name ? { name: r.name, email: r.email } : { email: r.email }); + +interface UseComposeSendParams { + toRecipients: Recipient[]; + ccRecipients: Recipient[]; + bccRecipients: Recipient[]; + subject: string; + editor: Editor | null; + onSent: () => void; +} + +interface UseComposeSendResult { + encryptionState: EncryptionState; + isSending: boolean; + send: () => Promise; +} + +/** + * Owns the compose dialog's send pipeline: recipient classification, recipient + * key lookup, body/preview encryption and dispatch of the send mutation. Keeps + * `ComposeMessageDialog` focused on rendering and wiring callbacks. + */ +export const useComposeSend = ({ + toRecipients, + ccRecipients, + bccRecipients, + subject, + editor, + onSent, +}: UseComposeSendParams): UseComposeSendResult => { + const { translate } = useTranslationContext(); + + const { data: activeDomains } = useGetActiveDomainsQuery(); + const { data: senderKeys } = useGetMailAccountKeysQuery(); + const [triggerLookup] = useLazyLookupRecipientKeysQuery(); + const [sendEmail, { isLoading: isSending }] = useSendEmailMutation(); + + const allRecipients = useMemo( + () => [...toRecipients, ...ccRecipients, ...bccRecipients], + [toRecipients, ccRecipients, bccRecipients], + ); + + const encryptionState = useMemo(() => { + if (allRecipients.length === 0) return 'none'; + if (!activeDomains) return 'unknown'; + return classifyRecipients( + allRecipients.map((r) => r.email), + activeDomains, + ).allInternxt + ? 'encrypted' + : 'cleartext'; + }, [allRecipients, activeDomains]); + + const send = useCallback(async () => { + if (allRecipients.length === 0) { + notificationsService.show({ + text: translate('errors.mail.noRecipients'), + type: ToastType.Warning, + }); + return; + } + + if (encryptionState === 'unknown') { + notificationsService.show({ + text: translate('errors.mail.encryptionUnavailable'), + type: ToastType.Error, + }); + return; + } + + 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, + textBody: textBody || undefined, + htmlBody: htmlBody || undefined, + }; + + try { + if (encryptionState === 'encrypted') { + if (!senderKeys?.address || !senderKeys.publicKey) { + notificationsService.show({ + text: translate('errors.mail.keyLookupFailed'), + type: ToastType.Error, + }); + return; + } + const uniqueAddresses = uniqueEmailAddresses(allRecipients.map((r) => r.email)); + let lookup: RecipientKey[]; + try { + lookup = await triggerLookup({ addresses: uniqueAddresses }).unwrap(); + } catch { + notificationsService.show({ + text: translate('errors.mail.keyLookupFailed'), + type: ToastType.Error, + }); + return; + } + const usable = lookup.filter((r): r is { address: string; publicKey: string } => Boolean(r.publicKey)); + + if (usable.length !== uniqueAddresses.length) { + notificationsService.show({ + text: translate('errors.mail.keyLookupFailed'), + type: ToastType.Error, + }); + return; + } + + 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, + encryption, + }).unwrap(); + } else { + await sendEmail(cleartextPayload).unwrap(); + } + onSent(); + } catch { + notificationsService.show({ + text: translate('errors.mail.sendFailed'), + type: ToastType.Error, + }); + } + }, [ + allRecipients, + editor, + toRecipients, + ccRecipients, + bccRecipients, + subject, + encryptionState, + senderKeys, + triggerLookup, + sendEmail, + onSent, + translate, + ]); + + return { encryptionState, isSending, send }; +}; + +export default useComposeSend; diff --git a/src/components/compose-message/index.tsx b/src/components/compose-message/index.tsx index 00d022a..70f5311 100644 --- a/src/components/compose-message/index.tsx +++ b/src/components/compose-message/index.tsx @@ -1,4 +1,4 @@ -import { PaperclipIcon, XIcon } from '@phosphor-icons/react'; +import { LockKeyIcon, PaperclipIcon, WarningIcon, XIcon } from '@phosphor-icons/react'; import { useCallback } from 'react'; import type { Recipient } from './types'; import { RecipientInput } from './components/RecipientInput'; @@ -8,6 +8,7 @@ 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 { useEditor } from '@tiptap/react'; import { EDITOR_CONFIG } from './config'; @@ -49,11 +50,14 @@ export const ComposeMessageDialog = () => { onComposeMessageDialogClose(ActionDialog.ComposeMessage); }, [onComposeMessageDialogClose]); - const handlePrimaryAction = useCallback(() => { - const html = editor?.getHTML(); - console.log('html', html); - onClose(); - }, [editor, onClose]); + const { encryptionState, isSending, send } = useComposeSend({ + toRecipients, + ccRecipients, + bccRecipients, + subject: subjectValue, + editor, + onSent: onClose, + }); if (!editor) return null; @@ -100,7 +104,7 @@ export const ComposeMessageDialog = () => { showBccButton={!showBcc} ccButtonText={translate('modals.composeMessageDialog.cc')} bccButtonText={translate('modals.composeMessageDialog.bcc')} - disabled={false} + disabled={isSending} /> {showCc && ( { recipients={ccRecipients} onAddRecipient={(email) => onAddCcRecipient?.(email)} onRemoveRecipient={(id) => onRemoveCcRecipient?.(id)} - disabled={false} + disabled={isSending} /> )} {showBcc && ( @@ -117,28 +121,46 @@ export const ComposeMessageDialog = () => { recipients={bccRecipients} onAddRecipient={(email) => onAddBccRecipient?.(email)} onRemoveRecipient={(id) => onRemoveBccRecipient?.(id)} - disabled={false} + disabled={isSending} /> )}

{translate('modals.composeMessageDialog.subject')}

- +
- +
{/* !TODO: Handle attachments */} -
- -
diff --git a/src/errors/mail/index.ts b/src/errors/mail/index.ts index 04821be..3432e72 100644 --- a/src/errors/mail/index.ts +++ b/src/errors/mail/index.ts @@ -85,3 +85,52 @@ export class DeleteEmailError extends Error { Object.setPrototypeOf(this, DeleteEmailError.prototype); } } + +export class SendEmailError extends Error { + constructor( + errorMsg?: string, + public requestId?: string, + ) { + super('Error while sending email: ' + errorMsg); + + Object.setPrototypeOf(this, SendEmailError.prototype); + } +} + +export class FetchRecipientKeysError extends Error { + constructor( + errorMsg?: string, + public requestId?: string, + ) { + super('Error while fetching recipient keys: ' + errorMsg); + + Object.setPrototypeOf(this, FetchRecipientKeysError.prototype); + } +} + +export class FetchActiveDomainsError extends Error { + constructor( + errorMsg?: string, + public requestId?: string, + ) { + super('Error while fetching active domains: ' + errorMsg); + + Object.setPrototypeOf(this, FetchActiveDomainsError.prototype); + } +} + +export class BuildEncryptionBlockError extends Error { + constructor() { + super('At least one recipient is required to build the encryption block'); + + Object.setPrototypeOf(this, BuildEncryptionBlockError.prototype); + } +} + +export class EnvelopeDecryptionError extends Error { + constructor() { + super('Cannot decrypt envelope: not a recipient or wrong key'); + + Object.setPrototypeOf(this, EnvelopeDecryptionError.prototype); + } +} diff --git a/src/features/mail/MailView.tsx b/src/features/mail/MailView.tsx index 8af252b..cdc5907 100644 --- a/src/features/mail/MailView.tsx +++ b/src/features/mail/MailView.tsx @@ -13,6 +13,8 @@ import { ErrorService } from '@/services/error'; import useListFolderPaginated from '@/hooks/mail/useListFolderPaginated'; import { useUnreadByMailbox } from '@/hooks/mail/useUnreadByMailbox'; import { useMailSelection } from '@/hooks/mail/useMailSelection'; +import { useDecryptedMail } from '@/hooks/mail/useDecryptedMail'; +import { useDecryptedPreviews } from '@/hooks/mail/useDecryptedPreviews'; import PreviewEmailEmptyState from './components/mail-preview/preview-empty-state'; import TrayHeader from './components/tray/header'; import { Tray } from '@internxt/ui'; @@ -35,6 +37,7 @@ const MailView = ({ folder }: MailViewProps) => { const { data: activeMailData } = useGetMailMessageQuery({ emailId: activeMailId! }, { skip: !activeMailId }); const activeMail = activeMailId ? activeMailData : undefined; + const decrypted = useDecryptedMail(activeMail); const { isLoadingListFolder, listFolderEmails, @@ -95,7 +98,8 @@ const MailView = ({ folder }: MailViewProps) => { } }; - const formattedMails = formatEmailsToList(listFolderEmails) ?? []; + const decryptedPreviews = useDecryptedPreviews(listFolderEmails); + const formattedMails = formatEmailsToList(listFolderEmails, decryptedPreviews) ?? []; return (
@@ -138,16 +142,19 @@ const MailView = ({ folder }: MailViewProps) => { - {activeMail && from && ( + {activeMail && ( ({ name: u.name ?? '', email: u.email }))} cc={cc.map((u) => ({ name: u.name ?? '', email: u.email }))} bcc={bcc.map((u) => ({ name: u.name ?? '', email: u.email }))} mail={{ - subject: activeMail.subject, + subject: decrypted.subject || activeMail.subject, receivedAt: activeMail.receivedAt, - htmlBody: activeMail.htmlBody ?? '', + htmlBody: decrypted.htmlBody, + 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 25f03ca..b076f06 100644 --- a/src/features/mail/components/mail-preview/index.tsx +++ b/src/features/mail/components/mail-preview/index.tsx @@ -1,3 +1,5 @@ +import { LockKeyIcon, WarningIcon } from '@phosphor-icons/react'; +import { useTranslationContext } from '@/i18n'; import PreviewHeader, { type User } from './header'; import Preview from './preview'; @@ -10,14 +12,37 @@ interface PreviewMailProps { subject: string; receivedAt: string; htmlBody: string; + isEncrypted?: boolean; + isDecrypting?: boolean; + decryptError?: boolean; }; } -const PreviewMail = ({ from, to, cc, bcc, mail }: PreviewMailProps) => ( -
- - -
-); +const PreviewMail = ({ from, to, cc, bcc, mail }: PreviewMailProps) => { + const { translate } = useTranslationContext(); + + return ( +
+ + {mail.isEncrypted && !mail.decryptError && ( +
+ + {translate('modals.composeMessageDialog.encryptedBadge')} +
+ )} + {mail.decryptError && ( +
+ + {translate('mail.preview.decryptFailed')} +
+ )} + {mail.isDecrypting ? ( +
{translate('mail.preview.decrypting')}
+ ) : ( + + )} +
+ ); +}; export default PreviewMail; diff --git a/src/features/mail/components/tray/search/components/list/index.tsx b/src/features/mail/components/tray/search/components/list/index.tsx index 031d8b6..06bda4e 100644 --- a/src/features/mail/components/tray/search/components/list/index.tsx +++ b/src/features/mail/components/tray/search/components/list/index.tsx @@ -1,4 +1,5 @@ import { formatEmailsToList } from '@/utils/format-emails'; +import { useDecryptedPreviews } from '@/hooks/mail/useDecryptedPreviews'; import type { EmailListResponse } from '@internxt/sdk/dist/mail/types'; import { InfiniteScroll, MessageCheap, MessageCheapSkeleton } from '@internxt/ui'; @@ -11,7 +12,8 @@ interface SearchEmailListProps { } const SearchEmailList = ({ mails, hasMoreItems, loading, onLoadMore, onMailSelected }: SearchEmailListProps) => { - const formattedMails = formatEmailsToList(mails) ?? []; + const decryptedPreviews = useDecryptedPreviews(mails); + const formattedMails = formatEmailsToList(mails, decryptedPreviews) ?? []; const loader = (
{new Array(3).fill(0).map((_, index) => ( diff --git a/src/hooks/mail/useDecryptedMail.test.tsx b/src/hooks/mail/useDecryptedMail.test.tsx new file mode 100644 index 0000000..39213a2 --- /dev/null +++ b/src/hooks/mail/useDecryptedMail.test.tsx @@ -0,0 +1,103 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import type { HybridKeyPair } from 'internxt-crypto'; +import type { EmailResponse } from '@internxt/sdk/dist/mail/types'; +import { useDecryptedMail } from './useDecryptedMail'; +import { useMailKeys } from './useMailKeys'; +import { MailEncryptionService } from '@/services/mail-encryption'; + +vi.mock('./useMailKeys', () => ({ useMailKeys: vi.fn() })); + +const mockKeys = vi.mocked(useMailKeys); + +const keypair = {} as HybridKeyPair; +const envelope = { version: 'v1' } as ReturnType; + +const spyOnEncryption = () => ({ + isEncrypted: vi.spyOn(MailEncryptionService.instance, 'isEncryptedEmailBody'), + parse: vi.spyOn(MailEncryptionService.instance, 'parseEncryptionBlock'), + decrypt: vi.spyOn(MailEncryptionService.instance, 'decryptEnvelope'), +}); + +let mailEncryption: ReturnType; + +const buildMail = (overrides: Partial = {}): EmailResponse => + ({ + id: 'mail-1', + subject: 'Weekly sync notes', + htmlBody: '

plain html

', + textBody: 'encrypted-wire', + ...overrides, + }) as EmailResponse; + +describe('useDecryptedMail', () => { + beforeEach(() => { + vi.restoreAllMocks(); + mailEncryption = spyOnEncryption(); + mockKeys.mockReturnValue(keypair); + mailEncryption.parse.mockReturnValue(envelope); + }); + + test('When there is no mail, then it reports an empty, non-encrypted state', () => { + mailEncryption.isEncrypted.mockReturnValue(false); + + const { result } = renderHook(() => useDecryptedMail(undefined)); + + expect(result.current).toStrictEqual({ + subject: '', + htmlBody: '', + isEncrypted: false, + isDecrypting: false, + decryptError: false, + }); + }); + + test('When the mail is not encrypted, then it returns the original content without decrypting', () => { + mailEncryption.isEncrypted.mockReturnValue(false); + const mail = buildMail(); + + const { result } = renderHook(() => useDecryptedMail(mail)); + + expect(result.current.subject).toBe('Weekly sync notes'); + expect(result.current.htmlBody).toBe('

plain html

'); + expect(result.current.isEncrypted).toBe(false); + expect(mailEncryption.decrypt).not.toHaveBeenCalled(); + }); + + test('When an encrypted mail is decrypted successfully, then it returns the decrypted body and clears the decrypting state', async () => { + mailEncryption.isEncrypted.mockReturnValue(true); + mailEncryption.decrypt.mockResolvedValue('the secret body'); + const mail = buildMail(); + + const { result } = renderHook(() => useDecryptedMail(mail)); + + await waitFor(() => expect(result.current.htmlBody).toBe('the secret body')); + expect(result.current.isEncrypted).toBe(true); + expect(result.current.isDecrypting).toBe(false); + expect(result.current.decryptError).toBe(false); + }); + + test('When an encrypted mail cannot be decrypted, then it surfaces a decrypt error', async () => { + mailEncryption.isEncrypted.mockReturnValue(true); + mailEncryption.decrypt.mockRejectedValue(new Error('not a recipient')); + const mail = buildMail(); + + const { result } = renderHook(() => useDecryptedMail(mail)); + + await waitFor(() => expect(result.current.decryptError).toBe(true)); + expect(result.current.htmlBody).toBe(''); + expect(result.current.isDecrypting).toBe(false); + }); + + test('While an encrypted mail is being decrypted, then it reports the decrypting state with the cleartext subject', () => { + mailEncryption.isEncrypted.mockReturnValue(true); + mailEncryption.decrypt.mockReturnValue(new Promise(() => undefined)); + const mail = buildMail(); + + const { result } = renderHook(() => useDecryptedMail(mail)); + + expect(result.current.isDecrypting).toBe(true); + expect(result.current.subject).toBe('Weekly sync notes'); + expect(result.current.htmlBody).toBe(''); + }); +}); diff --git a/src/hooks/mail/useDecryptedMail.ts b/src/hooks/mail/useDecryptedMail.ts new file mode 100644 index 0000000..5cda6a4 --- /dev/null +++ b/src/hooks/mail/useDecryptedMail.ts @@ -0,0 +1,89 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { EmailResponse } from '@internxt/sdk/dist/mail/types'; +import type { HybridKeyPair } from 'internxt-crypto'; +import { useMailKeys } from './useMailKeys'; +import { MailEncryptionService } from '@/services/mail-encryption'; + +type State = { + subject: string; + htmlBody: string; + isEncrypted: boolean; + isDecrypting: boolean; + decryptError: boolean; +}; + +const EMPTY: State = { + subject: '', + htmlBody: '', + isEncrypted: false, + isDecrypting: false, + decryptError: false, +}; + +type CachedResult = { ok: true; text: string } | { ok: false }; + +const decryptMailBody = async (mail: EmailResponse, senderKeys: HybridKeyPair): Promise => { + try { + const envelope = MailEncryptionService.instance.parseEncryptionBlock(mail.textBody as string); + const text = await MailEncryptionService.instance.decryptEnvelope(envelope, senderKeys); + return { ok: true, text }; + } catch (error) { + console.error('Failed to decrypt mail body', error); + return { ok: false }; + } +}; + +export const useDecryptedMail = (mail: EmailResponse | undefined): State => { + const senderKeys = useMailKeys(); + + const isEncrypted = mail ? MailEncryptionService.instance.isEncryptedEmailBody(mail.textBody) : false; + const canDecrypt = Boolean(isEncrypted && senderKeys); + + const [cached, setCached] = useState>({}); + + useEffect(() => { + if (!canDecrypt || !mail || !senderKeys) return; + if (cached[mail.id]) return; + + let cancelled = false; + decryptMailBody(mail, senderKeys).then((result) => { + if (!cancelled) setCached((prev) => ({ ...prev, [mail.id]: result })); + }); + + return () => { + cancelled = true; + }; + }, [canDecrypt, mail, senderKeys, cached]); + + return useMemo(() => { + if (!mail) return EMPTY; + + if (!isEncrypted) { + return { + subject: mail.subject, + htmlBody: mail.htmlBody ?? '', + isEncrypted: false, + isDecrypting: false, + decryptError: false, + }; + } + + const fresh = cached[mail.id] ?? null; + + if (!fresh) { + return { subject: mail.subject, htmlBody: '', isEncrypted: true, isDecrypting: true, decryptError: false }; + } + + if (!fresh.ok) { + return { subject: mail.subject, htmlBody: '', isEncrypted: true, isDecrypting: false, decryptError: true }; + } + + return { + subject: mail.subject, + htmlBody: fresh.text, + isEncrypted: true, + isDecrypting: false, + decryptError: false, + }; + }, [mail, isEncrypted, cached]); +}; diff --git a/src/hooks/mail/useDecryptedPreviews.test.tsx b/src/hooks/mail/useDecryptedPreviews.test.tsx new file mode 100644 index 0000000..26364d5 --- /dev/null +++ b/src/hooks/mail/useDecryptedPreviews.test.tsx @@ -0,0 +1,75 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import type { HybridKeyPair } from 'internxt-crypto'; +import type { EmailListResponse } from '@internxt/sdk/dist/mail/types'; +import { useDecryptedPreviews } from './useDecryptedPreviews'; +import { useMailKeys } from './useMailKeys'; +import { MailEncryptionService } from '@/services/mail-encryption'; + +vi.mock('./useMailKeys', () => ({ useMailKeys: vi.fn() })); + +const mockKeys = vi.mocked(useMailKeys); + +type Summary = EmailListResponse['emails'][number]; + +const keypair = {} as HybridKeyPair; +const encryptedSummary = (id: string): Summary => + ({ id, encryption: { encryptedPreview: 'ep', wrappedKeys: [] } }) as unknown as Summary; +const plainSummary = (id: string): Summary => ({ id }) as unknown as Summary; + +const spyOnPreviewDecrypt = () => vi.spyOn(MailEncryptionService.instance, 'decryptSummaryPreview'); +let mockDecrypt: ReturnType; + +describe('useDecryptedPreviews', () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockDecrypt = spyOnPreviewDecrypt(); + mockKeys.mockReturnValue(keypair); + }); + + test('When the caller has no keys, then no previews are decrypted', () => { + mockKeys.mockReturnValue(null); + + const { result } = renderHook(() => useDecryptedPreviews([encryptedSummary('a')])); + + expect(result.current).toStrictEqual({}); + expect(mockDecrypt).not.toHaveBeenCalled(); + }); + + test('When an encrypted summary is decryptable, then its decrypted preview is returned keyed by email id', async () => { + mockDecrypt.mockResolvedValue('decrypted snippet'); + + const { result } = renderHook(() => useDecryptedPreviews([encryptedSummary('a')])); + + await waitFor(() => expect(result.current).toStrictEqual({ a: 'decrypted snippet' })); + }); + + test('When a summary has no encryption block, then it is skipped', async () => { + const { result } = renderHook(() => useDecryptedPreviews([plainSummary('a')])); + + await waitFor(() => expect(mockKeys).toHaveBeenCalled()); + expect(mockDecrypt).not.toHaveBeenCalled(); + expect(result.current).toStrictEqual({}); + }); + + test('When a summary cannot be decrypted, then it is omitted from the result', async () => { + mockDecrypt.mockRejectedValue(new Error('not a recipient')); + + const { result } = renderHook(() => useDecryptedPreviews([encryptedSummary('a')])); + + await waitFor(() => expect(mockDecrypt).toHaveBeenCalled()); + expect(result.current).toStrictEqual({}); + }); + + test('When the same summaries are processed again, then decryption runs at most once per row', async () => { + mockDecrypt.mockResolvedValue('snippet'); + const summaries = [encryptedSummary('a')]; + + const { result, rerender } = renderHook(() => useDecryptedPreviews(summaries)); + await waitFor(() => expect(result.current).toStrictEqual({ a: 'snippet' })); + + rerender(); + + expect(mockDecrypt).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/mail/useDecryptedPreviews.ts b/src/hooks/mail/useDecryptedPreviews.ts new file mode 100644 index 0000000..2245d53 --- /dev/null +++ b/src/hooks/mail/useDecryptedPreviews.ts @@ -0,0 +1,56 @@ +import { useEffect, useRef, useState } from 'react'; +import type { EmailListResponse } from '@internxt/sdk/dist/mail/types'; +import type { HybridKeyPair } from 'internxt-crypto'; +import { useMailKeys } from './useMailKeys'; +import { MailEncryptionService } from '@/services/mail-encryption'; + +type Summary = EmailListResponse['emails'][number]; + +const decryptPendingPreviews = async (pending: Summary[], keypair: HybridKeyPair): Promise> => { + const resolved: Record = {}; + for (const summary of pending) { + try { + resolved[summary.id] = await MailEncryptionService.instance.decryptSummaryPreview(summary.encryption!, keypair); + } catch (error) { + console.error('Failed to decrypt mail preview', { mailId: summary.id, error }); + } + } + return resolved; +}; + +/** + * Decrypts the preview snippet for the encrypted rows on a list page. The + * backend projects an `encryption` block ({ encryptedPreview, wrappedKeys }) + * onto each encrypted summary the caller can read; we trial-decrypt it with the + * caller's keypair, exactly as the full body is decrypted. + * + * Returns a map of `emailId -> decrypted preview`. Rows are decrypted at most + * once (tracked in `attempted`), so re-renders and pagination don't re-run the + * crypto, and a row that fails simply stays absent + */ +export const useDecryptedPreviews = (summaries: Summary[] | undefined): Record => { + const keypair = useMailKeys(); + const [previews, setPreviews] = useState>({}); + const attempted = useRef>(new Set()); + + useEffect(() => { + if (!keypair || !summaries?.length) return; + + const pending = summaries.filter((s) => s.encryption && !attempted.current.has(s.id)); + if (pending.length === 0) return; + pending.forEach((s) => attempted.current.add(s.id)); + + let cancelled = false; + decryptPendingPreviews(pending, keypair).then((resolved) => { + if (!cancelled && Object.keys(resolved).length) { + setPreviews((prev) => ({ ...prev, ...resolved })); + } + }); + + return () => { + cancelled = true; + }; + }, [summaries, keypair]); + + return previews; +}; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 1716e06..cbc2b19 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -116,6 +116,8 @@ "to": "To", "cc": "CC", "bcc": "BCC", + "decrypting": "Decrypting message…", + "decryptFailed": "Could not decrypt this message", "emptyEmail": { "unreadEmails": { "title": "Unread messages", @@ -148,7 +150,11 @@ "markAsRead": "Something went wrong while marking the email as read", "markAsUnread": "Something went wrong while marking the email as unread", "trash": "Something went wrong while deleting the email", - "move": "Something went wrong while moving the email" + "move": "Something went wrong while moving the email", + "noRecipients": "Add at least one recipient before sending", + "sendFailed": "Could not send the email", + "keyLookupFailed": "Could not fetch recipient keys", + "encryptionUnavailable": "Encryption isn't ready yet. Please try again in a moment." } }, "modals": { @@ -187,7 +193,9 @@ "cc": "Cc", "bcc": "Bcc", "subject": "Subject", - "message": "Message" + "message": "Message", + "encryptedBadge": "End-to-end encrypted", + "cleartextBadge": "Not encrypted" }, "preferences": { "title": "Preferences", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 1c8af60..6fcbcc8 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -118,6 +118,8 @@ "to": "Para", "cc": "CC", "bcc": "CCO", + "decrypting": "Descifrando mensaje…", + "decryptFailed": "No se pudo descifrar este mensaje", "emptyEmail": { "unreadEmails": { "title": "Mensajes sin leer", @@ -150,7 +152,11 @@ "markAsRead": "Algo ha ido mal al marcar el correo como leído", "markAsUnread": "Algo ha ido mal al marcar el correo como no leído", "trash": "Algo ha ido mal al eliminar el correo", - "move": "Algo ha ido mal al mover el correo" + "move": "Algo ha ido mal al mover el correo", + "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", + "encryptionUnavailable": "El cifrado aún no está listo. Inténtalo de nuevo en un momento." } }, "modals": { @@ -189,7 +195,9 @@ "cc": "CC", "bcc": "CCO", "subject": "Asunto", - "message": "Mensaje" + "message": "Mensaje", + "encryptedBadge": "Cifrado de extremo a extremo", + "cleartextBadge": "Sin cifrar" }, "preferences": { "title": "Preferencias", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 3328d3e..108f4db 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -118,6 +118,8 @@ "to": "À", "cc": "CC", "bcc": "CCI", + "decrypting": "Déchiffrement du message…", + "decryptFailed": "Impossible de déchiffrer ce message", "emptyEmail": { "unreadEmails": { "title": "Messages non lus", @@ -150,7 +152,11 @@ "markAsRead": "Une erreur s'est produite lors du marquage de l'e-mail comme lu", "markAsUnread": "Une erreur s'est produite lors du marquage de l'e-mail comme non lu", "trash": "Une erreur s'est produite lors de la suppression de l'e-mail", - "move": "Une erreur s'est produite lors du déplacement de l'e-mail" + "move": "Une erreur s'est produite lors du déplacement de l'e-mail", + "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", + "encryptionUnavailable": "Le chiffrement n'est pas encore prêt. Veuillez réessayer dans un instant." } }, "modals": { @@ -189,7 +195,9 @@ "cc": "Cc", "bcc": "Cci", "subject": "Objet", - "message": "Message" + "message": "Message", + "encryptedBadge": "Chiffré de bout en bout", + "cleartextBadge": "Non chiffré" }, "preferences": { "title": "Préférences", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index c745024..8f4df58 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -118,6 +118,8 @@ "to": "A", "cc": "CC", "bcc": "CCN", + "decrypting": "Decrittazione del messaggio…", + "decryptFailed": "Impossibile decrittare questo messaggio", "emptyEmail": { "unreadEmails": { "title": "Messaggi non letti", @@ -150,7 +152,11 @@ "markAsRead": "Si è verificato un errore durante la marcatura dell'e-mail come letta", "markAsUnread": "Si è verificato un errore durante la marcatura dell'e-mail come non letta", "trash": "Si è verificato un errore durante l'eliminazione dell'e-mail", - "move": "Si è verificato un errore durante lo spostamento dell'e-mail" + "move": "Si è verificato un errore durante lo spostamento dell'e-mail", + "noRecipients": "Aggiungi almeno un destinatario prima di inviare", + "sendFailed": "Impossibile inviare l'email", + "keyLookupFailed": "Impossibile recuperare le chiavi del destinatario", + "encryptionUnavailable": "La crittografia non è ancora pronta. Riprova tra un momento." } }, "modals": { @@ -189,7 +195,9 @@ "cc": "CC", "bcc": "CCN", "subject": "Oggetto", - "message": "Messaggio" + "message": "Messaggio", + "encryptedBadge": "Crittografia end-to-end", + "cleartextBadge": "Non crittografato" }, "preferences": { "title": "Preferenze", diff --git a/src/services/mail-encryption/index.test.ts b/src/services/mail-encryption/index.test.ts new file mode 100644 index 0000000..5a2f0cb --- /dev/null +++ b/src/services/mail-encryption/index.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { generateEmailKeys, uint8ArrayToBase64 } from 'internxt-crypto'; +import { ENCRYPTED_EMAIL_PREFIX, MailEncryptionService, type RecipientPublicKey } from '.'; + +const mailEncryption = MailEncryptionService.instance; +const content = (body: string, previewText = body) => ({ body, previewText }); + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe('buildEncryptionBlock + decryptEnvelope', () => { + test('When a message is encrypted for one recipient, then that recipient can read the original body', async () => { + const bob = await generateEmailKeys(); + + const recipients: RecipientPublicKey[] = [{ address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }]; + + const envelope = await mailEncryption.buildEncryptionBlock(content('

hi bob

'), recipients); + + expect(envelope.version).toBe('v1'); + expect(Array.isArray(envelope.wrappedKeys)).toBe(true); + expect(envelope.wrappedKeys).toHaveLength(1); + + const text = await mailEncryption.decryptEnvelope(envelope, bob); + expect(text).toBe('

hi bob

'); + }); + + test('When a message is encrypted, then the subject is never part of the envelope', async () => { + const bob = await generateEmailKeys(); + + const envelope = await mailEncryption.buildEncryptionBlock(content('body'), [ + { address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }, + ]); + + expect(envelope).not.toHaveProperty('encryptedSubject'); + }); + + test('When a message is encrypted for multiple recipients, then each recipient can read the original body', async () => { + const alice = await generateEmailKeys(); + const bob = await generateEmailKeys(); + + const envelope = await mailEncryption.buildEncryptionBlock(content('hey team'), [ + { address: 'alice@inxt.me', publicKey: uint8ArrayToBase64(alice.publicKey) }, + { address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }, + ]); + + const aliceView = await mailEncryption.decryptEnvelope(envelope, alice); + const bobView = await mailEncryption.decryptEnvelope(envelope, bob); + + expect(aliceView).toBe('hey team'); + expect(bobView).toBe('hey team'); + }); + + test('When a multi-recipient message includes a Bcc, then the serialized envelope leaks no recipient address', async () => { + const sender = await generateEmailKeys(); + const to = await generateEmailKeys(); + const cc = await generateEmailKeys(); + const bcc = await generateEmailKeys(); + + const addresses = ['sender@inxt.me', 'to@inxt.me', 'cc@inxt.me', 'secret-bcc@inxt.me']; + const envelope = await mailEncryption.buildEncryptionBlock(content('hidden recipients'), [ + { address: addresses[0], publicKey: uint8ArrayToBase64(sender.publicKey) }, + { address: addresses[1], publicKey: uint8ArrayToBase64(to.publicKey) }, + { address: addresses[2], publicKey: uint8ArrayToBase64(cc.publicKey) }, + { address: addresses[3], publicKey: uint8ArrayToBase64(bcc.publicKey) }, + ]); + + const wire = `${ENCRYPTED_EMAIL_PREFIX}\n${Buffer.from(JSON.stringify(envelope)).toString('base64')}`; + const serialized = JSON.stringify(envelope); + for (const addr of addresses) { + expect(serialized).not.toContain(addr); + expect(wire).not.toContain(addr); + } + expect(serialized).not.toContain('secret-bcc'); + + expect(envelope.wrappedKeys).toHaveLength(4); + for (const entry of envelope.wrappedKeys) { + expect(Object.keys(entry).sort()).toStrictEqual(['encryptedKey', 'hybridCiphertext']); + } + + expect(await mailEncryption.decryptEnvelope(envelope, bcc)).toBe('hidden recipients'); + expect(await mailEncryption.decryptEnvelope(envelope, sender)).toBe('hidden recipients'); + expect(await mailEncryption.decryptEnvelope(envelope, to)).toBe('hidden recipients'); + expect(await mailEncryption.decryptEnvelope(envelope, cc)).toBe('hidden recipients'); + }); + + test('When no recipients are provided, then encryption should fail', async () => { + await expect(mailEncryption.buildEncryptionBlock(content('t'), [])).rejects.toThrow(); + }); + + test('When decrypting with a key that was not a recipient, then decryption should fail cleanly', async () => { + const bob = await generateEmailKeys(); + const eve = await generateEmailKeys(); + const envelope = await mailEncryption.buildEncryptionBlock(content('y'), [ + { address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }, + ]); + + await expect(mailEncryption.decryptEnvelope(envelope, eve)).rejects.toThrow(/not a recipient or wrong key/); + }); +}); + +describe('encrypted preview', () => { + test('When the body is long, then the preview is a whitespace-collapsed truncation a recipient can decrypt', async () => { + const bob = await generateEmailKeys(); + const previewText = `First line.\n\n Second line with spaces.${' tail'.repeat(200)}`; + + const envelope = await mailEncryption.buildEncryptionBlock({ body: '

full body

', previewText }, [ + { address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }, + ]); + + const preview = await mailEncryption.decryptSummaryPreview( + { encryptedPreview: envelope.encryptedPreview, wrappedKeys: envelope.wrappedKeys }, + bob, + ); + + expect(preview.length).toBe(256); + expect(preview.startsWith('First line. Second line with spaces.')).toBe(true); + expect(preview).not.toContain('\n'); + }); + + test('When a non-recipient tries to read the preview, then it fails cleanly', async () => { + const bob = await generateEmailKeys(); + const eve = await generateEmailKeys(); + const envelope = await mailEncryption.buildEncryptionBlock(content('

body

', 'snippet'), [ + { address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }, + ]); + + expect( + await mailEncryption.decryptSummaryPreview( + { encryptedPreview: envelope.encryptedPreview, wrappedKeys: envelope.wrappedKeys }, + bob, + ), + ).toBe('snippet'); + await expect( + mailEncryption.decryptSummaryPreview( + { encryptedPreview: envelope.encryptedPreview, wrappedKeys: envelope.wrappedKeys }, + eve, + ), + ).rejects.toThrow(/not a recipient or wrong key/); + }); +}); + +describe('isEncryptedEmailBody / parseEncryptionBlock', () => { + test('When checking whether a body is encrypted, then only prefixed bodies should be recognized as encrypted', () => { + expect(mailEncryption.isEncryptedEmailBody(`${ENCRYPTED_EMAIL_PREFIX}\nabc`)).toBe(true); + expect(mailEncryption.isEncryptedEmailBody('plain body')).toBe(false); + expect(mailEncryption.isEncryptedEmailBody(null)).toBe(false); + expect(mailEncryption.isEncryptedEmailBody(undefined)).toBe(false); + }); + + test('When the body contains a valid encrypted bundle, then it should parse the encryption block', () => { + const block = { + version: 'v1' as const, + encryptedText: 'et', + encryptedPreview: 'ep', + wrappedKeys: [{ hybridCiphertext: 'h', encryptedKey: 'k' }], + }; + const wire = `${ENCRYPTED_EMAIL_PREFIX}\n${Buffer.from(JSON.stringify(block)).toString('base64')}`; + expect(mailEncryption.parseEncryptionBlock(wire)).toStrictEqual(block); + }); +}); diff --git a/src/services/mail-encryption/index.ts b/src/services/mail-encryption/index.ts new file mode 100644 index 0000000..5edf414 --- /dev/null +++ b/src/services/mail-encryption/index.ts @@ -0,0 +1,139 @@ +import { base64ToUint8Array, type HybridKeyPair } from 'internxt-crypto'; +import { + decryptEmail, + decryptKeysHybrid, + encryptEmail, + encryptEmailWithKey, + encryptKeysHybrid, +} from 'internxt-crypto/email-crypto'; +import type { EncryptionBlock } from '@internxt/sdk/dist/mail/types'; +import { BuildEncryptionBlockError, EnvelopeDecryptionError } from '@/errors/mail'; + +export type RecipientPublicKey = { address: string; publicKey: string }; +export type EmailContent = { body: string; previewText: string }; +type WrappedKey = EncryptionBlock['wrappedKeys'][number]; +export type EncryptedSummary = { encryptedPreview: string; wrappedKeys: WrappedKey[] }; +const PREVIEW_PLAINTEXT_LENGTH = 256; + +export const ENCRYPTED_EMAIL_PREFIX = 'INTERNXT-ENCRYPTED-EMAIL-v1'; + +function buildPreviewSnippet(previewText: string): string { + return previewText.replace(/\s+/g, ' ').trim().slice(0, PREVIEW_PLAINTEXT_LENGTH); +} + +function secureShuffle(items: T[]): T[] { + const rand = new Uint32Array(1); + for (let i = items.length - 1; i > 0; i--) { + const range = i + 1; + const limit = Math.floor(0x1_00_00_00_00 / range) * range; + let value: number; + do { + crypto.getRandomValues(rand); + value = rand[0]; + } while (value >= limit); + const j = value % range; + [items[i], items[j]] = [items[j], items[i]]; + } + return items; +} + +export class MailEncryptionService { + public static readonly instance: MailEncryptionService = new MailEncryptionService(); + + private constructor() {} + + /** + * Only the body and preview are encrypted; the subject travels as cleartext so + * the backend can index it. + * + * The wrapped keys ship as a de-identified, order-randomized array carrying no + * recipient address, so the envelope hides the recipient set (Bcc included) — + * each recipient finds their entry by trial decryption (see `decryptEnvelope`). + */ + async buildEncryptionBlock(content: EmailContent, recipients: RecipientPublicKey[]): Promise { + if (recipients.length === 0) { + throw new BuildEncryptionBlockError(); + } + + const { encEmail, encryptionKey } = await encryptEmail({ text: content.body }); + + const { encText: encryptedPreview } = await encryptEmailWithKey( + { text: buildPreviewSnippet(content.previewText) }, + encryptionKey, + ); + + const wrapped = await Promise.all( + recipients.map(async (r) => { + const enc = await encryptKeysHybrid(encryptionKey, { + email: r.address, + publicHybridKey: base64ToUint8Array(r.publicKey), + }); + return { hybridCiphertext: enc.hybridCiphertext, encryptedKey: enc.encryptedKey }; + }), + ); + const wrappedKeys: WrappedKey[] = secureShuffle(wrapped); + + return { + version: 'v1', + encryptedText: encEmail.encText, + encryptedPreview, + wrappedKeys, + }; + } + + isEncryptedEmailBody(textBody: string | null | undefined): boolean { + if (!textBody) return false; + return textBody.startsWith(`${ENCRYPTED_EMAIL_PREFIX}\n`); + } + + parseEncryptionBlock(textBody: string): EncryptionBlock { + const payload = textBody.slice(ENCRYPTED_EMAIL_PREFIX.length + 1); + const json = typeof atob === 'function' ? atob(payload) : Buffer.from(payload, 'base64').toString('utf8'); + return JSON.parse(json) as EncryptionBlock; + } + + /** + * Trial-decrypts a ciphertext sealed with the shared body key. The wrapped keys + * carry no recipient identifier, so we try each one and keep the entry whose key + * yields a valid AEAD tag. + * + * @throws if none decrypt — the caller is not a recipient or holds the wrong key. + */ + private async trialDecrypt( + wrappedKeys: WrappedKey[], + ciphertextB64: string, + keypair: HybridKeyPair, + ): Promise { + for (const wrapped of wrappedKeys) { + try { + const bodyKey = await decryptKeysHybrid( + { hybridCiphertext: wrapped.hybridCiphertext, encryptedKey: wrapped.encryptedKey, encryptedForEmail: '' }, + keypair.secretKey, + ); + const { text } = await decryptEmail({ encText: ciphertextB64 }, bodyKey); + return text; + } catch { + // No op, try the next one. + } + } + throw new EnvelopeDecryptionError(); + } + + /** + * Decrypts the email body from its envelope using the caller's keypair. + * @returns the cleartext body. + * @throws {EnvelopeDecryptionError} if the caller is not a recipient (see `trialDecrypt`). + */ + decryptEnvelope(envelope: EncryptionBlock, keypair: HybridKeyPair): Promise { + return this.trialDecrypt(envelope.wrappedKeys, envelope.encryptedText, keypair); + } + + /** + * Decrypts the list preview snippet from an encrypted summary using the caller's keypair. + * @returns the cleartext preview snippet. + * @throws {EnvelopeDecryptionError} if the caller is not a recipient (see `trialDecrypt`). + */ + decryptSummaryPreview(summary: EncryptedSummary, keypair: HybridKeyPair): Promise { + return this.trialDecrypt(summary.wrappedKeys, summary.encryptedPreview, keypair); + } +} diff --git a/src/services/recipient-keys/index.test.ts b/src/services/recipient-keys/index.test.ts new file mode 100644 index 0000000..aa4bb33 --- /dev/null +++ b/src/services/recipient-keys/index.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { RecipientKeysService } from '.'; + +describe('RecipientKeysService', () => { + beforeEach(() => { + vi.restoreAllMocks(); + RecipientKeysService.instance.clear(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-18T00:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('When a public key is set for an address, then it can be retrieved by that address', () => { + RecipientKeysService.instance.set('alice@inxt.me', 'pk'); + expect(RecipientKeysService.instance.get('alice@inxt.me')?.publicKey).toBe('pk'); + }); + + test('When the lookup address differs only in case from the stored one, then the key is still found', () => { + RecipientKeysService.instance.set('Alice@INXT.me', 'pk'); + expect(RecipientKeysService.instance.has('alice@inxt.me')).toBe(true); + }); + + test('When the TTL has elapsed since the key was stored, then get returns null', () => { + RecipientKeysService.instance.set('alice@inxt.me', 'pk'); + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + expect(RecipientKeysService.instance.get('alice@inxt.me')).toBeNull(); + }); + + test('When an address resolves to no key, then the miss is cached and reported as present', () => { + RecipientKeysService.instance.set('stranger@gmail.com', null); + + expect(RecipientKeysService.instance.get('stranger@gmail.com')?.publicKey).toBeNull(); + expect(RecipientKeysService.instance.has('stranger@gmail.com')).toBe(true); + }); + + test('When the entry returned by get is mutated, then the cached entry stays unchanged', () => { + RecipientKeysService.instance.set('alice@inxt.me', 'pk'); + + const entry = RecipientKeysService.instance.get('alice@inxt.me'); + if (entry) entry.publicKey = 'tampered'; + + expect(RecipientKeysService.instance.get('alice@inxt.me')?.publicKey).toBe('pk'); + }); + + test('When clear is called, then all previously cached keys are removed', () => { + RecipientKeysService.instance.set('a@inxt.me', 'pk1'); + RecipientKeysService.instance.set('b@inxt.me', 'pk2'); + RecipientKeysService.instance.clear(); + expect(RecipientKeysService.instance.get('a@inxt.me')).toBeNull(); + expect(RecipientKeysService.instance.get('b@inxt.me')).toBeNull(); + }); +}); diff --git a/src/services/recipient-keys/index.ts b/src/services/recipient-keys/index.ts new file mode 100644 index 0000000..6a9c5f9 --- /dev/null +++ b/src/services/recipient-keys/index.ts @@ -0,0 +1,41 @@ +const DEFAULT_TTL_MS = 5 * 60 * 1000; + +type CachedKey = { publicKey: string | null; fetchedAt: number }; + +export class RecipientKeysService { + public static readonly instance: RecipientKeysService = new RecipientKeysService(); + + private constructor() {} + + private readonly cache = new Map(); + + private normalize(address: string): string { + return address.trim().toLowerCase(); + } + + set(address: string, publicKey: string | null): void { + this.cache.set(this.normalize(address), { publicKey, fetchedAt: Date.now() }); + } + + /** + * Returns a defensive copy of the cached entry for `address`, or null when it was + * never looked up or the entry is older than `ttlMs` (expired entries are evicted). + */ + get(address: string, ttlMs: number = DEFAULT_TTL_MS): CachedKey | null { + const entry = this.cache.get(this.normalize(address)); + if (!entry) return null; + if (Date.now() - entry.fetchedAt > ttlMs) { + this.cache.delete(this.normalize(address)); + return null; + } + return { ...entry }; + } + + has(address: string, ttlMs: number = DEFAULT_TTL_MS): boolean { + return this.get(address, ttlMs) !== null; + } + + clear(): void { + this.cache.clear(); + } +} diff --git a/src/services/sdk/mail/index.ts b/src/services/sdk/mail/index.ts index df6a87d..0d28d3d 100644 --- a/src/services/sdk/mail/index.ts +++ b/src/services/sdk/mail/index.ts @@ -1,12 +1,15 @@ import type { + EmailCreatedResponse, EmailDomainsResponse, EmailListResponse, EmailResponse, ListEmailsQuery, + LookupRecipientKeysResponse, MailAccountKeysResponse, MailAccountResponse, MailboxResponse, SearchFiltersQuery, + SendEmailRequest, SetupMailAccountPayload, UpdateEmailRequest, } from '@internxt/sdk/dist/mail/types'; @@ -116,4 +119,27 @@ export class MailService { async trashEmail(emailId: string): Promise { return this.client.deleteEmail(emailId); } + + /** + * Sends an email. The recipient/encryption decision is made by the caller — + * pass `encryption` for an end-to-end-encrypted send, omit it for cleartext. + * + * @param payload - The send request (recipients, subject, body, optional encryption block) + * @returns The id of the created email + */ + async sendEmail(payload: SendEmailRequest): Promise { + return this.client.sendEmail(payload); + } + + /** + * Looks up the public encryption keys for a batch of recipient addresses. + * Returns `publicKey: null` for external or unknown addresses, which the + * caller should treat as a signal to fall back to cleartext. + * + * @param addresses - 1-50 email addresses + * @returns A list of `{ address, publicKey | null }` + */ + async lookupRecipientKeys(addresses: string[]): Promise { + return this.client.lookupRecipientKeys(addresses); + } } diff --git a/src/services/sdk/mail/mail.service.test.ts b/src/services/sdk/mail/mail.service.test.ts index a183a61..c2eb309 100644 --- a/src/services/sdk/mail/mail.service.test.ts +++ b/src/services/sdk/mail/mail.service.test.ts @@ -300,6 +300,87 @@ describe('Mail Service', () => { }); }); + describe('Send email', () => { + test('When sending a cleartext email, then the client should be called with the payload', async () => { + const payload = { + to: [{ email: 'bob@inxt.me' }], + subject: 'hi', + textBody: 'hello', + }; + const mockMailClient = { + sendEmail: vi.fn().mockResolvedValue({ id: 'mail-1' }), + } as any; + vi.spyOn(SdkManager.instance, 'getMail').mockReturnValue(mockMailClient); + + const result = await MailService.instance.sendEmail(payload); + + expect(result).toStrictEqual({ id: 'mail-1' }); + expect(mockMailClient.sendEmail).toHaveBeenCalledWith(payload); + }); + + test('When sending an encrypted email, then the encryption block should be forwarded', async () => { + const payload = { + to: [{ email: 'bob@inxt.me' }], + subject: 'Weekly sync notes', + encryption: { + version: 'v1' as const, + encryptedText: 'enc-text', + encryptedPreview: 'enc-preview', + wrappedKeys: [{ hybridCiphertext: 'ct', encryptedKey: 'ek' }], + }, + }; + const mockMailClient = { + sendEmail: vi.fn().mockResolvedValue({ id: 'mail-2' }), + } as any; + vi.spyOn(SdkManager.instance, 'getMail').mockReturnValue(mockMailClient); + + const result = await MailService.instance.sendEmail(payload); + + expect(result).toStrictEqual({ id: 'mail-2' }); + expect(mockMailClient.sendEmail).toHaveBeenCalledWith(payload); + }); + + test('When sending fails, then an error should be thrown', async () => { + const unexpectedError = new Error('Unexpected error'); + const mockMailClient = { + sendEmail: vi.fn().mockRejectedValue(unexpectedError), + } as any; + vi.spyOn(SdkManager.instance, 'getMail').mockReturnValue(mockMailClient); + + await expect(MailService.instance.sendEmail({ to: [{ email: 'x@inxt.me' }], subject: 's' })).rejects.toThrow( + unexpectedError, + ); + }); + }); + + describe('Lookup recipient keys', () => { + test('When looking up keys, then the addresses should be forwarded and recipients returned', async () => { + const recipients = [ + { address: 'alice@inxt.me', publicKey: 'pk-alice' }, + { address: 'bob@external.com', publicKey: null }, + ]; + const mockMailClient = { + lookupRecipientKeys: vi.fn().mockResolvedValue({ recipients }), + } as any; + vi.spyOn(SdkManager.instance, 'getMail').mockReturnValue(mockMailClient); + + const result = await MailService.instance.lookupRecipientKeys(['alice@inxt.me', 'bob@external.com']); + + expect(result).toStrictEqual({ recipients }); + expect(mockMailClient.lookupRecipientKeys).toHaveBeenCalledWith(['alice@inxt.me', 'bob@external.com']); + }); + + test('When lookup fails, then an error should be thrown', async () => { + const unexpectedError = new Error('Unexpected error'); + const mockMailClient = { + lookupRecipientKeys: vi.fn().mockRejectedValue(unexpectedError), + } as any; + vi.spyOn(SdkManager.instance, 'getMail').mockReturnValue(mockMailClient); + + await expect(MailService.instance.lookupRecipientKeys(['a@inxt.me'])).rejects.toThrow(unexpectedError); + }); + }); + describe('Trashing email', () => { test('When trashing email, then the client should be called with the correct params', async () => { const mockMailClient = { diff --git a/src/store/api/base.ts b/src/store/api/base.ts index b2a847a..9df3e89 100644 --- a/src/store/api/base.ts +++ b/src/store/api/base.ts @@ -3,6 +3,16 @@ import { createApi, fakeBaseQuery } from '@reduxjs/toolkit/query/react'; export const api = createApi({ reducerPath: 'api', baseQuery: fakeBaseQuery(), - tagTypes: ['Mailbox', 'ListFolder', 'MailMessage', 'MailAccountKeys', 'MailMe', 'StorageUsage', 'StorageLimit'], + tagTypes: [ + 'Mailbox', + 'ListFolder', + 'MailMessage', + 'MailAccountKeys', + 'MailMe', + 'StorageUsage', + 'StorageLimit', + 'RecipientKeys', + 'ActiveDomains', + ], endpoints: () => ({}), }); diff --git a/src/store/api/mail/index.ts b/src/store/api/mail/index.ts index e08e0f6..c5d8b4b 100644 --- a/src/store/api/mail/index.ts +++ b/src/store/api/mail/index.ts @@ -1,28 +1,49 @@ import { api } from '../base'; import { + DeleteEmailError, + FetchActiveDomainsError, + FetchListFolderError, FetchMailAccountKeysError, FetchMailboxesInfoError, FetchMailMeError, FetchMessageError, - FetchListFolderError, + FetchRecipientKeysError, MAIL_NOT_SETUP_CODE, MailNotSetupError, + SendEmailError, UpdateMailError, - DeleteEmailError, } from '@/errors'; import { ErrorService } from '@/services/error'; +import { RecipientKeysService } from '@/services/recipient-keys'; import { MailService, type MailMeResponse } from '@/services/sdk/mail'; import type { FolderType } from '@/types/mail'; import { batchProcess } from '@/utils/batch-processes'; import type { + EmailCreatedResponse, + EmailDomainsResponse, EmailListResponse, EmailResponse, ListEmailsQuery, + LookupRecipientKeysResponse, MailAccountKeysResponse, MailboxResponse, + RecipientKey, + SendEmailRequest, } from '@internxt/sdk/dist/mail/types'; import type { AppDispatch } from '@/store'; +const normalizeLookupAddresses = (addresses: string[]): string[] => { + const seen = new Set(); + const out: string[] = []; + for (const raw of addresses) { + const normalized = raw.trim().toLowerCase(); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + out.push(normalized); + } + return out.sort((a, b) => a.localeCompare(b)); +}; + const patchMailsAfterAction = async ({ dispatch, sourceMailbox, @@ -225,6 +246,50 @@ export const mailApi = api.injectEndpoints({ }); }, }), + getActiveDomains: builder.query({ + async queryFn(): Promise<{ data: EmailDomainsResponse } | { error: FetchActiveDomainsError }> { + try { + const domains = await MailService.instance.getActiveDomains(); + return { data: domains }; + } catch (error) { + const err = ErrorService.instance.castError(error); + return { error: new FetchActiveDomainsError(err.message, err.requestId) }; + } + }, + providesTags: ['ActiveDomains'], + }), + lookupRecipientKeys: builder.query({ + serializeQueryArgs: ({ queryArgs }) => ({ + addresses: normalizeLookupAddresses(queryArgs.addresses).join(','), + }), + async queryFn({ addresses }): Promise<{ data: RecipientKey[] } | { error: FetchRecipientKeysError }> { + const normalized = normalizeLookupAddresses(addresses); + if (normalized.length === 0) return { data: [] }; + try { + const res: LookupRecipientKeysResponse = await MailService.instance.lookupRecipientKeys(normalized); + for (const r of res.recipients) { + RecipientKeysService.instance.set(r.address, r.publicKey); + } + return { data: res.recipients }; + } catch (error) { + const err = ErrorService.instance.castError(error); + return { error: new FetchRecipientKeysError(err.message, err.requestId) }; + } + }, + providesTags: ['RecipientKeys'], + }), + sendEmail: builder.mutation({ + async queryFn(payload): Promise<{ data: EmailCreatedResponse } | { error: SendEmailError }> { + try { + const result = await MailService.instance.sendEmail(payload); + return { data: result }; + } catch (error) { + const err = ErrorService.instance.castError(error); + return { error: new SendEmailError(err.message, err.requestId) }; + } + }, + invalidatesTags: [{ type: 'ListFolder', id: 'sent' }, 'Mailbox'], + }), }), }); @@ -237,4 +302,8 @@ export const { useUpdateReadStatusMutation, useDeleteMailsMutation, useMoveToFolderMutation, + useGetActiveDomainsQuery, + useLookupRecipientKeysQuery, + useLazyLookupRecipientKeysQuery, + useSendEmailMutation, } = mailApi; diff --git a/src/store/api/mail/mail.api.test.ts b/src/store/api/mail/mail.api.test.ts index 7cd274c..cd9a7bb 100644 --- a/src/store/api/mail/mail.api.test.ts +++ b/src/store/api/mail/mail.api.test.ts @@ -2,15 +2,19 @@ import { describe, test, expect, vi, beforeEach } from 'vitest'; import { ErrorService } from '@/services/error'; import { DeleteEmailError, + FetchActiveDomainsError, FetchListFolderError, FetchMailAccountKeysError, FetchMailboxesInfoError, FetchMailMeError, FetchMessageError, + FetchRecipientKeysError, MAIL_NOT_SETUP_CODE, MailNotSetupError, + SendEmailError, UpdateMailError, } from '@/errors'; +import { RecipientKeysService } from '@/services/recipient-keys'; import { MailService } from '@/services/sdk/mail'; import { getMockedMail, getMockedMailBoxes, getMockedMails } from '@/test-utils/fixtures'; import { mailApi } from '.'; @@ -556,4 +560,127 @@ describe('Mail API', () => { expect(result.error).toBeInstanceOf(FetchMailAccountKeysError); }); }); + + describe('Get active domains', () => { + test('When fetching active domains, then the service should be called and the list returned', async () => { + const domains = [ + { + id: '1', + domain: 'inxt.me', + status: 'active', + createdAt: '2026-05-18T00:00:00.000Z', + updatedAt: '2026-05-18T00:00:00.000Z', + }, + ]; + vi.spyOn(MailService.instance, 'getActiveDomains').mockResolvedValue(domains); + const store = createTestStore(); + + const result = await store.dispatch(mailApi.endpoints.getActiveDomains.initiate()); + + expect(result.data).toStrictEqual(domains); + }); + + test('When fetching active domains fails, then a FetchActiveDomainsError should be returned', async () => { + vi.spyOn(MailService.instance, 'getActiveDomains').mockRejectedValue(new Error('boom')); + const store = createTestStore(); + + const result = await store.dispatch(mailApi.endpoints.getActiveDomains.initiate()); + + expect(result.error).toBeInstanceOf(FetchActiveDomainsError); + }); + }); + + describe('Lookup recipient keys', () => { + test('When looking up keys, then it returns the recipient list and writes through to the cache, including misses', async () => { + RecipientKeysService.instance.clear(); + const recipients = [ + { address: 'alice@inxt.me', publicKey: 'pk-alice' }, + { address: 'bob@gmail.com', publicKey: null }, + ]; + vi.spyOn(MailService.instance, 'lookupRecipientKeys').mockResolvedValue({ recipients }); + const store = createTestStore(); + + const result = await store.dispatch( + mailApi.endpoints.lookupRecipientKeys.initiate({ addresses: ['alice@inxt.me', 'bob@gmail.com'] }), + ); + + expect(result.data).toStrictEqual(recipients); + expect(RecipientKeysService.instance.get('alice@inxt.me')?.publicKey).toBe('pk-alice'); + expect(RecipientKeysService.instance.get('bob@gmail.com')?.publicKey).toBeNull(); + expect(RecipientKeysService.instance.has('bob@gmail.com')).toBe(true); + }); + + test('When lookup fails, then a FetchRecipientKeysError should be returned', async () => { + vi.spyOn(MailService.instance, 'lookupRecipientKeys').mockRejectedValue(new Error('boom')); + const store = createTestStore(); + + const result = await store.dispatch(mailApi.endpoints.lookupRecipientKeys.initiate({ addresses: ['x@inxt.me'] })); + + expect(result.error).toBeInstanceOf(FetchRecipientKeysError); + }); + + test('When addresses include whitespace, case variants, and duplicates, then the backend is called once with a normalized unique list', async () => { + RecipientKeysService.instance.clear(); + const recipients = [ + { address: 'alice@inxt.me', publicKey: 'pk-alice' }, + { address: 'bob@inxt.me', publicKey: 'pk-bob' }, + ]; + const spy = vi.spyOn(MailService.instance, 'lookupRecipientKeys').mockResolvedValue({ recipients }); + const store = createTestStore(); + + const result = await store.dispatch( + mailApi.endpoints.lookupRecipientKeys.initiate({ + addresses: [' Alice@inxt.me ', 'alice@INXT.me', 'Bob@inxt.me', 'bob@inxt.me'], + }), + ); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(['alice@inxt.me', 'bob@inxt.me']); + expect(result.data).toStrictEqual(recipients); + expect(RecipientKeysService.instance.get('alice@inxt.me')?.publicKey).toBe('pk-alice'); + expect(RecipientKeysService.instance.get('bob@inxt.me')?.publicKey).toBe('pk-bob'); + }); + + test('When the addresses array is empty, then it returns an empty list and does not call the backend', async () => { + RecipientKeysService.instance.clear(); + const spy = vi.spyOn(MailService.instance, 'lookupRecipientKeys'); + const store = createTestStore(); + + const result = await store.dispatch(mailApi.endpoints.lookupRecipientKeys.initiate({ addresses: [] })); + + expect(spy).not.toHaveBeenCalled(); + expect(result.data).toStrictEqual([]); + }); + }); + + describe('Send email', () => { + test('When sending succeeds, then it returns the created email id', async () => { + vi.spyOn(MailService.instance, 'sendEmail').mockResolvedValue({ id: 'mail-1' }); + const store = createTestStore(); + + const result = await store.dispatch( + mailApi.endpoints.sendEmail.initiate({ + to: [{ email: 'bob@inxt.me' }], + subject: 'hi', + textBody: 'hello', + }), + ); + + expect('data' in result && result.data).toStrictEqual({ id: 'mail-1' }); + }); + + test('When sending fails, then a SendEmailError should be returned', async () => { + vi.spyOn(MailService.instance, 'sendEmail').mockRejectedValue(new Error('boom')); + const store = createTestStore(); + + const result = await store.dispatch( + mailApi.endpoints.sendEmail.initiate({ + to: [{ email: 'bob@inxt.me' }], + subject: 'hi', + }), + ); + + expect('error' in result && result.error).toBeInstanceOf(SendEmailError); + }); + }); }); diff --git a/src/utils/domain/index.test.ts b/src/utils/domain/index.test.ts new file mode 100644 index 0000000..cff3da9 --- /dev/null +++ b/src/utils/domain/index.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from 'vitest'; +import { classifyRecipients, getDomain, isInternxtDomain, uniqueEmailAddresses } from '.'; + +const internxtDomains = [{ domain: 'inxt.me' }, { domain: 'internxt.com' }]; + +describe('getDomain', () => { + test('When the address is valid, then it should return the lowercase domain', () => { + expect(getDomain('Alice@INXT.me')).toBe('inxt.me'); + }); + + test('When the address is malformed, then it should return null', () => { + expect(getDomain('no-at-sign')).toBeNull(); + expect(getDomain('@nothing')).toBeNull(); + expect(getDomain('nothing@')).toBeNull(); + }); + + test('When the local part contains an at-sign in quotes, then it should return the domain after the last separator', () => { + expect(getDomain('"a@b"@inxt.me')).toBe('inxt.me'); + }); +}); + +describe('isInternxtDomain', () => { + test('When the domain belongs to Internxt, then it should return true regardless of casing', () => { + expect(isInternxtDomain('alice@INXT.ME', internxtDomains)).toBe(true); + expect(isInternxtDomain('bob@internxt.com', internxtDomains)).toBe(true); + }); + + test('When the domain is external, then it should return false', () => { + expect(isInternxtDomain('eve@gmail.com', internxtDomains)).toBe(false); + }); + + test('When the address is malformed, then it should return false', () => { + expect(isInternxtDomain('not-an-email', internxtDomains)).toBe(false); + }); +}); + +describe('uniqueEmailAddresses', () => { + test('When the same address appears more than once, then it should return a single entry', () => { + expect(uniqueEmailAddresses(['alice@inxt.me', 'alice@inxt.me'])).toEqual(['alice@inxt.me']); + }); + + test('When addresses differ only by casing, then it should keep one entry', () => { + expect(uniqueEmailAddresses(['Alice@INXT.me', 'alice@inxt.me'])).toEqual(['alice@inxt.me']); + }); + + test('When multiple different addresses are provided, then it should keep each distinct one', () => { + expect(uniqueEmailAddresses(['a@inxt.me', 'b@inxt.me', 'a@inxt.me'])).toEqual(['a@inxt.me', 'b@inxt.me']); + }); +}); + +describe('classifyRecipients', () => { + test('When every recipient uses an Internxt domain, then all recipients should be classified as internal', () => { + const result = classifyRecipients(['a@inxt.me', 'b@internxt.com'], internxtDomains); + expect(result.allInternxt).toBe(true); + expect(result.internxt).toHaveLength(2); + expect(result.external).toHaveLength(0); + }); + + test('When any recipient uses an external domain, then recipients should be split into internal and external', () => { + const result = classifyRecipients(['a@inxt.me', 'b@gmail.com'], internxtDomains); + expect(result.allInternxt).toBe(false); + expect(result.internxt).toEqual(['a@inxt.me']); + expect(result.external).toEqual(['b@gmail.com']); + }); + + test('When the recipient list is empty, then not all recipients should be classified as internal', () => { + expect(classifyRecipients([], internxtDomains).allInternxt).toBe(false); + }); +}); diff --git a/src/utils/domain/index.ts b/src/utils/domain/index.ts new file mode 100644 index 0000000..65162ad --- /dev/null +++ b/src/utils/domain/index.ts @@ -0,0 +1,41 @@ +export function getDomain(address: string): string | null { + const trimmed = address.trim().toLowerCase(); + const at = trimmed.lastIndexOf('@'); + if (at < 1 || at === trimmed.length - 1) return null; + return trimmed.slice(at + 1); +} + +export function isInternxtDomain(address: string, activeDomains: { domain: string }[]): boolean { + const domain = getDomain(address); + if (!domain) return false; + return activeDomains.some((d) => d.domain.trim().toLowerCase() === domain); +} + +export function uniqueEmailAddresses(addresses: string[]): string[] { + const seen = new Set(); + const unique: string[] = []; + for (const address of addresses) { + const normalized = address.trim().toLowerCase(); + if (seen.has(normalized)) continue; + seen.add(normalized); + unique.push(normalized); + } + return unique; +} + +export function classifyRecipients( + recipients: string[], + activeDomains: { domain: string }[], +): { allInternxt: boolean; internxt: string[]; external: string[] } { + const internxt: string[] = []; + const external: string[] = []; + for (const r of recipients) { + if (isInternxtDomain(r, activeDomains)) internxt.push(r); + else external.push(r); + } + return { + allInternxt: recipients.length > 0 && external.length === 0, + internxt, + external, + }; +} diff --git a/src/utils/format-emails/index.test.ts b/src/utils/format-emails/index.test.ts index 3e404ce..0a5df9c 100644 --- a/src/utils/format-emails/index.test.ts +++ b/src/utils/format-emails/index.test.ts @@ -32,4 +32,17 @@ describe('Formatting emails to list format', () => { expect(DateService.formatMailTimestamp).toHaveBeenCalledWith(emails[0].receivedAt); }); }); + + test('When a row is encrypted, then it uses the decrypted preview when available and never the ciphertext', () => { + const { emails } = getMockedMails(2); + emails.forEach((e) => { + (e as { encryption?: unknown }).encryption = { encryptedPreview: 'ep', wrappedKeys: [] }; + }); + + const result = formatEmailsToList(emails, { [emails[0].id]: 'decrypted snippet' }); + + expect(result?.[0].body).toBe('decrypted snippet'); + expect(result?.[1].body).toBe(''); + expect(result?.[1].body).not.toBe(emails[1].preview); + }); }); diff --git a/src/utils/format-emails/index.ts b/src/utils/format-emails/index.ts index 3eab605..f39c5a2 100644 --- a/src/utils/format-emails/index.ts +++ b/src/utils/format-emails/index.ts @@ -1,7 +1,10 @@ import { DateService } from '@/services/date'; import type { EmailListResponse } from '@internxt/sdk/dist/mail/types'; -export const formatEmailsToList = (listFolderEmails?: EmailListResponse['emails']) => { +export const formatEmailsToList = ( + listFolderEmails?: EmailListResponse['emails'], + decryptedPreviews?: Record, +) => { return listFolderEmails?.map((mail) => ({ id: mail.id, from: { @@ -10,7 +13,7 @@ export const formatEmailsToList = (listFolderEmails?: EmailListResponse['emails' }, subject: mail.subject, createdAt: DateService.formatMailTimestamp(mail.receivedAt), - body: mail.preview, + body: decryptedPreviews?.[mail.id] ?? (mail.encryption ? '' : mail.preview), read: mail.isRead, })); };