From 2f4efb7a02c93648b05619a82ee9a5af9f5e48ab Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Mon, 18 May 2026 16:09:28 -0600 Subject: [PATCH 01/12] feat(recipient-keys): implement RecipientKeysService for managing public keys and add lookup functionality in MailService --- src/errors/mail/index.ts | 11 +++++++ src/services/recipient-keys/index.test.ts | 38 ++++++++++++++++++++++ src/services/recipient-keys/index.ts | 37 +++++++++++++++++++++ src/services/sdk/mail/index.ts | 13 ++++++++ src/services/sdk/mail/mail.service.test.ts | 28 ++++++++++++++++ src/store/api/base.ts | 11 ++++++- src/store/api/mail/index.ts | 31 ++++++++++++++++-- src/store/api/mail/mail.api.test.ts | 31 ++++++++++++++++++ 8 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 src/services/recipient-keys/index.test.ts create mode 100644 src/services/recipient-keys/index.ts diff --git a/src/errors/mail/index.ts b/src/errors/mail/index.ts index 04821be..71284c0 100644 --- a/src/errors/mail/index.ts +++ b/src/errors/mail/index.ts @@ -85,3 +85,14 @@ export class DeleteEmailError extends Error { Object.setPrototypeOf(this, DeleteEmailError.prototype); } } + +export class FetchRecipientKeysError extends Error { + constructor( + errorMsg?: string, + public requestId?: string, + ) { + super('Error while fetching recipient keys: ' + errorMsg); + + Object.setPrototypeOf(this, FetchRecipientKeysError.prototype); + } +} diff --git a/src/services/recipient-keys/index.test.ts b/src/services/recipient-keys/index.test.ts new file mode 100644 index 0000000..e352d8c --- /dev/null +++ b/src/services/recipient-keys/index.test.ts @@ -0,0 +1,38 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { RecipientKeysService } from '.'; + +describe('RecipientKeysService', () => { + beforeEach(() => { + RecipientKeysService.instance.clear(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-18T00:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('stores and retrieves a public key', () => { + RecipientKeysService.instance.set('alice@inxt.me', 'pk'); + expect(RecipientKeysService.instance.get('alice@inxt.me')?.publicKey).toBe('pk'); + }); + + test('matches addresses case-insensitively', () => { + RecipientKeysService.instance.set('Alice@INXT.me', 'pk'); + expect(RecipientKeysService.instance.has('alice@inxt.me')).toBe(true); + }); + + test('returns null after the TTL has passed', () => { + RecipientKeysService.instance.set('alice@inxt.me', 'pk'); + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + expect(RecipientKeysService.instance.get('alice@inxt.me')).toBeNull(); + }); + + test('clear() empties the cache', () => { + 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..0ca4778 --- /dev/null +++ b/src/services/recipient-keys/index.ts @@ -0,0 +1,37 @@ +const DEFAULT_TTL_MS = 5 * 60 * 1000; + +type CachedKey = { publicKey: string; fetchedAt: number }; + +export class RecipientKeysService { + public static readonly instance: RecipientKeysService = new RecipientKeysService(); + + private constructor() {} + + private cache = new Map(); + + private normalize(address: string): string { + return address.trim().toLowerCase(); + } + + set(address: string, publicKey: string): void { + this.cache.set(this.normalize(address), { publicKey, fetchedAt: Date.now() }); + } + + 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..54389a5 100644 --- a/src/services/sdk/mail/index.ts +++ b/src/services/sdk/mail/index.ts @@ -3,6 +3,7 @@ import type { EmailListResponse, EmailResponse, ListEmailsQuery, + LookupRecipientKeysResponse, MailAccountKeysResponse, MailAccountResponse, MailboxResponse, @@ -116,4 +117,16 @@ export class MailService { async trashEmail(emailId: string): Promise { return this.client.deleteEmail(emailId); } + + /** + * 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..d588d32 100644 --- a/src/services/sdk/mail/mail.service.test.ts +++ b/src/services/sdk/mail/mail.service.test.ts @@ -300,6 +300,34 @@ describe('Mail Service', () => { }); }); + 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..9559f96 100644 --- a/src/store/api/base.ts +++ b/src/store/api/base.ts @@ -3,6 +3,15 @@ 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', + ], endpoints: () => ({}), }); diff --git a/src/store/api/mail/index.ts b/src/store/api/mail/index.ts index e08e0f6..548c3c8 100644 --- a/src/store/api/mail/index.ts +++ b/src/store/api/mail/index.ts @@ -1,16 +1,18 @@ import { api } from '../base'; import { + DeleteEmailError, + FetchListFolderError, FetchMailAccountKeysError, FetchMailboxesInfoError, FetchMailMeError, FetchMessageError, - FetchListFolderError, + FetchRecipientKeysError, MAIL_NOT_SETUP_CODE, MailNotSetupError, 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'; @@ -18,8 +20,10 @@ import type { EmailListResponse, EmailResponse, ListEmailsQuery, + LookupRecipientKeysResponse, MailAccountKeysResponse, MailboxResponse, + RecipientKey, } from '@internxt/sdk/dist/mail/types'; import type { AppDispatch } from '@/store'; @@ -225,6 +229,27 @@ export const mailApi = api.injectEndpoints({ }); }, }), + lookupRecipientKeys: builder.query({ + serializeQueryArgs: ({ queryArgs }) => ({ + addresses: [...queryArgs.addresses] + .map((a) => a.toLowerCase()) + .sort() + .join(','), + }), + async queryFn({ addresses }): Promise<{ data: RecipientKey[] } | { error: FetchRecipientKeysError }> { + try { + const res: LookupRecipientKeysResponse = await MailService.instance.lookupRecipientKeys(addresses); + for (const r of res.recipients) { + if (r.publicKey) 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'], + }), }), }); @@ -237,4 +262,6 @@ export const { useUpdateReadStatusMutation, useDeleteMailsMutation, useMoveToFolderMutation, + useLookupRecipientKeysQuery, + useLazyLookupRecipientKeysQuery, } = mailApi; diff --git a/src/store/api/mail/mail.api.test.ts b/src/store/api/mail/mail.api.test.ts index 7cd274c..fbb6097 100644 --- a/src/store/api/mail/mail.api.test.ts +++ b/src/store/api/mail/mail.api.test.ts @@ -7,10 +7,12 @@ import { FetchMailboxesInfoError, FetchMailMeError, FetchMessageError, + FetchRecipientKeysError, MAIL_NOT_SETUP_CODE, MailNotSetupError, 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 +558,33 @@ describe('Mail API', () => { expect(result.error).toBeInstanceOf(FetchMailAccountKeysError); }); }); + + describe('Lookup recipient keys', () => { + test('When looking up keys, then it returns the recipient list and writes through to the cache', 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')).toBeNull(); + }); + + 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); + }); + }); }); From a7685f17ee45459156b7fde8aea3f89e5fc988b8 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Wed, 20 May 2026 15:38:49 -0600 Subject: [PATCH 02/12] feat(mail): enhance ComposeMessageDialog with email encryption, recipient key lookup, --- src/components/compose-message/index.tsx | 145 ++++++++++++++++-- src/errors/mail/index.ts | 22 +++ src/features/mail/MailView.tsx | 9 +- .../mail/components/mail-preview/index.tsx | 37 ++++- src/hooks/mail/useDecryptedMail.ts | 86 +++++++++++ src/i18n/locales/en.json | 12 +- src/i18n/locales/es.json | 12 +- src/i18n/locales/fr.json | 12 +- src/i18n/locales/it.json | 12 +- src/services/mail-encryption/index.test.ts | 76 +++++++++ src/services/mail-encryption/index.ts | 94 ++++++++++++ src/services/sdk/mail/index.ts | 13 ++ src/services/sdk/mail/mail.service.test.ts | 55 +++++++ src/store/api/base.ts | 1 + src/store/api/mail/index.ts | 31 ++++ src/store/api/mail/mail.api.test.ts | 62 ++++++++ src/utils/domain/index.test.ts | 69 +++++++++ src/utils/domain/index.ts | 41 +++++ 18 files changed, 762 insertions(+), 27 deletions(-) create mode 100644 src/hooks/mail/useDecryptedMail.ts create mode 100644 src/services/mail-encryption/index.test.ts create mode 100644 src/services/mail-encryption/index.ts create mode 100644 src/utils/domain/index.test.ts create mode 100644 src/utils/domain/index.ts diff --git a/src/components/compose-message/index.tsx b/src/components/compose-message/index.tsx index 00d022a..3827bac 100644 --- a/src/components/compose-message/index.tsx +++ b/src/components/compose-message/index.tsx @@ -1,5 +1,5 @@ -import { PaperclipIcon, XIcon } from '@phosphor-icons/react'; -import { useCallback } from 'react'; +import { LockKeyIcon, PaperclipIcon, WarningIcon, XIcon } from '@phosphor-icons/react'; +import { useCallback, useMemo } from 'react'; import type { Recipient } from './types'; import { RecipientInput } from './components/RecipientInput'; import { Button, Input } from '@internxt/ui'; @@ -10,6 +10,16 @@ import { useTranslationContext } from '@/i18n'; import useComposeMessage from './hooks/useComposeMessage'; 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 { buildEncryptionBlock, 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; @@ -19,6 +29,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(); @@ -45,15 +57,100 @@ 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 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(() => { onComposeMessageDialogClose(ActionDialog.ComposeMessage); }, [onComposeMessageDialogClose]); - const handlePrimaryAction = useCallback(() => { - const html = editor?.getHTML(); - console.log('html', html); - onClose(); - }, [editor, onClose]); + const handlePrimaryAction = useCallback(async () => { + if (allRecipients.length === 0) { + notificationsService.show({ + text: translate('modals.composeMessageDialog.errors.noRecipients'), + type: ToastType.Warning, + }); + 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: subjectValue, + textBody: textBody || undefined, + htmlBody: htmlBody || undefined, + }; + + 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 buildEncryptionBlock( + { subject: subjectValue, text: htmlBody || textBody }, + recipientsWithKeys, + ); + await sendEmail({ + to: toRecipients.map(toEmailAddress), + cc: ccRecipients.length ? ccRecipients.map(toEmailAddress) : undefined, + bcc: bccRecipients.length ? bccRecipients.map(toEmailAddress) : undefined, + subject: translate('modals.composeMessageDialog.encryptedPlaceholderSubject'), + encryption, + }).unwrap(); + } else { + await sendEmail(cleartextPayload).unwrap(); + } + } else { + await sendEmail(cleartextPayload).unwrap(); + } + onClose(); + } catch { + notificationsService.show({ + text: translate('modals.composeMessageDialog.errors.sendFailed'), + type: ToastType.Error, + }); + } + }, [ + allRecipients, + editor, + toRecipients, + ccRecipients, + bccRecipients, + subjectValue, + encryptionState, + senderKeys, + triggerLookup, + sendEmail, + onClose, + translate, + ]); if (!editor) return null; @@ -100,7 +197,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 +214,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 71284c0..054ec78 100644 --- a/src/errors/mail/index.ts +++ b/src/errors/mail/index.ts @@ -86,6 +86,17 @@ export class DeleteEmailError extends Error { } } +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, @@ -96,3 +107,14 @@ export class FetchRecipientKeysError extends Error { 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); + } +} diff --git a/src/features/mail/MailView.tsx b/src/features/mail/MailView.tsx index 8af252b..df452c4 100644 --- a/src/features/mail/MailView.tsx +++ b/src/features/mail/MailView.tsx @@ -13,6 +13,7 @@ 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 PreviewEmailEmptyState from './components/mail-preview/preview-empty-state'; import TrayHeader from './components/tray/header'; import { Tray } from '@internxt/ui'; @@ -35,6 +36,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, @@ -145,9 +147,12 @@ 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={{ - 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/hooks/mail/useDecryptedMail.ts b/src/hooks/mail/useDecryptedMail.ts new file mode 100644 index 0000000..571e52d --- /dev/null +++ b/src/hooks/mail/useDecryptedMail.ts @@ -0,0 +1,86 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { EmailResponse } from '@internxt/sdk/dist/mail/types'; +import { useMailKeys } from './useMailKeys'; +import { useGetMailAccountKeysQuery } from '@/store/api/mail'; +import { decryptEnvelope, isEncryptedEmailBody, parseEncryptionBlock } 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 = { mailId: string; ok: true; subject: string; text: string } | { mailId: string; ok: false }; + +export const useDecryptedMail = (mail: EmailResponse | undefined): State => { + const senderKeys = useMailKeys(); + const { data: account } = useGetMailAccountKeysQuery(); + + const isEncrypted = mail ? isEncryptedEmailBody(mail.textBody) : false; + const canDecrypt = Boolean(isEncrypted && senderKeys && account?.address); + + const [cached, setCached] = useState(null); + + useEffect(() => { + if (!canDecrypt || !mail || !senderKeys || !account?.address) return; + + let cancelled = false; + (async () => { + try { + const envelope = parseEncryptionBlock(mail.textBody as string); + const plaintext = await decryptEnvelope(envelope, account.address, senderKeys); + if (!cancelled) { + setCached({ mailId: mail.id, ok: true, subject: plaintext.subject, text: plaintext.text }); + } + } catch { + if (!cancelled) setCached({ mailId: mail.id, ok: false }); + } + })(); + + return () => { + cancelled = true; + }; + }, [canDecrypt, mail, senderKeys, account?.address]); + + return useMemo(() => { + if (!mail) return EMPTY; + + if (!isEncrypted) { + return { + subject: mail.subject, + htmlBody: mail.htmlBody ?? '', + isEncrypted: false, + isDecrypting: false, + decryptError: false, + }; + } + + const fresh = cached && cached.mailId === mail.id ? cached : 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: fresh.subject, + htmlBody: fresh.text, + isEncrypted: true, + isDecrypting: false, + decryptError: false, + }; + }, [mail, isEncrypted, cached]); +}; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 1716e06..87c03a3 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", @@ -187,7 +189,15 @@ "cc": "Cc", "bcc": "Bcc", "subject": "Subject", - "message": "Message" + "message": "Message", + "encryptedBadge": "End-to-end encrypted", + "cleartextBadge": "Not encrypted", + "encryptedPlaceholderSubject": "Encrypted message", + "errors": { + "noRecipients": "Add at least one recipient before sending", + "sendFailed": "Could not send the email", + "keyLookupFailed": "Could not fetch recipient keys" + } }, "preferences": { "title": "Preferences", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 1c8af60..9617cc9 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", @@ -189,7 +191,15 @@ "cc": "CC", "bcc": "CCO", "subject": "Asunto", - "message": "Mensaje" + "message": "Mensaje", + "encryptedBadge": "Cifrado de extremo a extremo", + "cleartextBadge": "Sin cifrar", + "encryptedPlaceholderSubject": "Mensaje cifrado", + "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" + } }, "preferences": { "title": "Preferencias", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 3328d3e..229b739 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", @@ -189,7 +191,15 @@ "cc": "Cc", "bcc": "Cci", "subject": "Objet", - "message": "Message" + "message": "Message", + "encryptedBadge": "Chiffré de bout en bout", + "cleartextBadge": "Non chiffré", + "encryptedPlaceholderSubject": "Message 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" + } }, "preferences": { "title": "Préférences", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index c745024..1954743 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", @@ -189,7 +191,15 @@ "cc": "CC", "bcc": "CCN", "subject": "Oggetto", - "message": "Messaggio" + "message": "Messaggio", + "encryptedBadge": "Crittografia end-to-end", + "cleartextBadge": "Non crittografato", + "encryptedPlaceholderSubject": "Messaggio crittografato", + "errors": { + "noRecipients": "Aggiungi almeno un destinatario prima di inviare", + "sendFailed": "Impossibile inviare l'email", + "keyLookupFailed": "Impossibile recuperare le chiavi del destinatario" + } }, "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..dbaac34 --- /dev/null +++ b/src/services/mail-encryption/index.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from 'vitest'; +import { generateEmailKeys, uint8ArrayToBase64 } from 'internxt-crypto'; +import { + ENCRYPTED_EMAIL_PREFIX, + buildEncryptionBlock, + decryptEnvelope, + isEncryptedEmailBody, + parseEncryptionBlock, + type RecipientPublicKey, +} from '.'; + +describe('buildEncryptionBlock + decryptEnvelope', () => { + test('When a message is encrypted for one recipient, then that recipient can read the original content', async () => { + const bob = await generateEmailKeys(); + + const recipients: RecipientPublicKey[] = [{ address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }]; + + const envelope = await buildEncryptionBlock({ subject: 'hello', text: '

hi bob

' }, recipients); + + expect(envelope.version).toBe('v1'); + expect(envelope.wrappedKeys['bob@inxt.me']).toBeDefined(); + + const plaintext = await decryptEnvelope(envelope, 'bob@inxt.me', bob); + expect(plaintext.subject).toBe('hello'); + expect(plaintext.text).toBe('

hi bob

'); + }); + + test('When a message is encrypted for multiple recipients, then each recipient can read the original content', async () => { + const alice = await generateEmailKeys(); + const bob = await generateEmailKeys(); + + const envelope = await buildEncryptionBlock({ subject: 'group hello', text: 'hey team' }, [ + { address: 'alice@inxt.me', publicKey: uint8ArrayToBase64(alice.publicKey) }, + { address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }, + ]); + + const aliceView = await decryptEnvelope(envelope, 'alice@inxt.me', alice); + const bobView = await decryptEnvelope(envelope, 'bob@inxt.me', bob); + + expect(aliceView.text).toBe('hey team'); + expect(bobView.text).toBe('hey team'); + }); + + test('When no recipients are provided, then encryption should fail', async () => { + await expect(buildEncryptionBlock({ subject: 's', text: 't' }, [])).rejects.toThrow(); + }); + + test('When decrypting with an address that was not a recipient, then decryption should fail', async () => { + const bob = await generateEmailKeys(); + const envelope = await buildEncryptionBlock({ subject: 'x', text: 'y' }, [ + { address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }, + ]); + + await expect(decryptEnvelope(envelope, 'eve@inxt.me', bob)).rejects.toThrow(); + }); +}); + +describe('isEncryptedEmailBody / parseEncryptionBlock', () => { + test('When checking whether a body is encrypted, then only prefixed bodies should be recognized as encrypted', () => { + expect(isEncryptedEmailBody(`${ENCRYPTED_EMAIL_PREFIX}\nabc`)).toBe(true); + expect(isEncryptedEmailBody('plain body')).toBe(false); + expect(isEncryptedEmailBody(null)).toBe(false); + expect(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, + encryptedSubject: 'es', + encryptedText: 'et', + wrappedKeys: { 'a@inxt.me': { hybridCiphertext: 'h', encryptedKey: 'k' } }, + }; + const wire = `${ENCRYPTED_EMAIL_PREFIX}\n${Buffer.from(JSON.stringify(block)).toString('base64')}`; + expect(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..6e1ffd1 --- /dev/null +++ b/src/services/mail-encryption/index.ts @@ -0,0 +1,94 @@ +import { + base64ToUint8Array, + decryptEmailHybrid, + encryptEmailHybridForMultipleRecipients, + type EmailBody, + type HybridEncryptedEmail, + type HybridKeyPair, +} from 'internxt-crypto'; +import type { EncryptionBlock } from '@internxt/sdk/dist/mail/types'; + +export type RecipientPublicKey = { address: string; publicKey: string }; + +/** + * Builds the on-the-wire encryption bundle for a send. All recipients share + * the same symmetric body ciphertext; each gets their own wrapped key. + * + * `publicKey` is the recipient's hybrid (X25519 + ML-KEM-768 / X-Wing) key, + * base64-encoded as returned by the backend lookup endpoint. + */ +export async function buildEncryptionBlock( + email: EmailBody, + recipients: RecipientPublicKey[], +): Promise { + if (recipients.length === 0) { + throw new Error('At least one recipient is required to build the encryption block'); + } + + const recipientsWithBytes = recipients.map((r) => ({ + email: r.address, + publicHybridKey: base64ToUint8Array(r.publicKey), + })); + + const sealed = await encryptEmailHybridForMultipleRecipients(email, recipientsWithBytes); + + const wrappedKeys: EncryptionBlock['wrappedKeys'] = {}; + for (const s of sealed) { + wrappedKeys[s.encryptedKey.encryptedForEmail] = { + hybridCiphertext: s.encryptedKey.hybridCiphertext, + encryptedKey: s.encryptedKey.encryptedKey, + }; + } + + return { + version: 'v1', + encryptedSubject: sealed[0].encEmailBody.encSubject, + encryptedText: sealed[0].encEmailBody.encText, + wrappedKeys, + }; +} + +export const ENCRYPTED_EMAIL_PREFIX = 'INTERNXT-ENCRYPTED-EMAIL-v1'; + +/** + * Detects the encryption envelope marker in a body. The backend wraps every + * encrypted send as `INTERNXT-ENCRYPTED-EMAIL-v1\n` (see + * `mail/src/modules/email/email.service.ts`). + */ +export function isEncryptedEmailBody(textBody: string | null | undefined): boolean { + if (!textBody) return false; + return textBody.startsWith(`${ENCRYPTED_EMAIL_PREFIX}\n`); +} + +export function 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; +} + +/** + * Decrypts an envelope using the recipient's hybrid keypair. The recipient + * address must match one of the wrappedKeys entries. + */ +export async function decryptEnvelope( + envelope: EncryptionBlock, + recipientAddress: string, + keypair: HybridKeyPair, +): Promise { + const wrapped = envelope.wrappedKeys[recipientAddress]; + if (!wrapped) { + throw new Error(`No wrapped key found for ${recipientAddress}`); + } + const sealed: HybridEncryptedEmail = { + encryptedKey: { + hybridCiphertext: wrapped.hybridCiphertext, + encryptedKey: wrapped.encryptedKey, + encryptedForEmail: recipientAddress, + }, + encEmailBody: { + encText: envelope.encryptedText, + encSubject: envelope.encryptedSubject, + }, + }; + return decryptEmailHybrid(sealed, keypair.secretKey); +} diff --git a/src/services/sdk/mail/index.ts b/src/services/sdk/mail/index.ts index 54389a5..0d28d3d 100644 --- a/src/services/sdk/mail/index.ts +++ b/src/services/sdk/mail/index.ts @@ -1,4 +1,5 @@ import type { + EmailCreatedResponse, EmailDomainsResponse, EmailListResponse, EmailResponse, @@ -8,6 +9,7 @@ import type { MailAccountResponse, MailboxResponse, SearchFiltersQuery, + SendEmailRequest, SetupMailAccountPayload, UpdateEmailRequest, } from '@internxt/sdk/dist/mail/types'; @@ -118,6 +120,17 @@ export class MailService { 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 diff --git a/src/services/sdk/mail/mail.service.test.ts b/src/services/sdk/mail/mail.service.test.ts index d588d32..c08bf0f 100644 --- a/src/services/sdk/mail/mail.service.test.ts +++ b/src/services/sdk/mail/mail.service.test.ts @@ -300,6 +300,61 @@ 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: 'Encrypted message', + encryption: { + version: 'v1' as const, + encryptedSubject: 'enc-subj', + encryptedText: 'enc-text', + wrappedKeys: { + 'bob@inxt.me': { 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 = [ diff --git a/src/store/api/base.ts b/src/store/api/base.ts index 9559f96..9df3e89 100644 --- a/src/store/api/base.ts +++ b/src/store/api/base.ts @@ -12,6 +12,7 @@ export const api = createApi({ 'StorageUsage', 'StorageLimit', 'RecipientKeys', + 'ActiveDomains', ], endpoints: () => ({}), }); diff --git a/src/store/api/mail/index.ts b/src/store/api/mail/index.ts index 548c3c8..ca4f1f1 100644 --- a/src/store/api/mail/index.ts +++ b/src/store/api/mail/index.ts @@ -1,6 +1,7 @@ import { api } from '../base'; import { DeleteEmailError, + FetchActiveDomainsError, FetchListFolderError, FetchMailAccountKeysError, FetchMailboxesInfoError, @@ -9,6 +10,7 @@ import { FetchRecipientKeysError, MAIL_NOT_SETUP_CODE, MailNotSetupError, + SendEmailError, UpdateMailError, } from '@/errors'; import { ErrorService } from '@/services/error'; @@ -17,6 +19,8 @@ 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, @@ -24,6 +28,7 @@ import type { MailAccountKeysResponse, MailboxResponse, RecipientKey, + SendEmailRequest, } from '@internxt/sdk/dist/mail/types'; import type { AppDispatch } from '@/store'; @@ -229,6 +234,18 @@ 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: [...queryArgs.addresses] @@ -250,6 +267,18 @@ export const mailApi = api.injectEndpoints({ }, 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'], + }), }), }); @@ -262,6 +291,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 fbb6097..ebac1ad 100644 --- a/src/store/api/mail/mail.api.test.ts +++ b/src/store/api/mail/mail.api.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect, vi, beforeEach } from 'vitest'; import { ErrorService } from '@/services/error'; import { DeleteEmailError, + FetchActiveDomainsError, FetchListFolderError, FetchMailAccountKeysError, FetchMailboxesInfoError, @@ -10,6 +11,7 @@ import { FetchRecipientKeysError, MAIL_NOT_SETUP_CODE, MailNotSetupError, + SendEmailError, UpdateMailError, } from '@/errors'; import { RecipientKeysService } from '@/services/recipient-keys'; @@ -559,6 +561,35 @@ describe('Mail API', () => { }); }); + 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', async () => { RecipientKeysService.instance.clear(); @@ -587,4 +618,35 @@ describe('Mail API', () => { expect(result.error).toBeInstanceOf(FetchRecipientKeysError); }); }); + + 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, + }; +} From c2039857db5d7ce2d0c298cf4a5f89906d9762d8 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Tue, 26 May 2026 15:39:19 -0600 Subject: [PATCH 03/12] feat(mail): implement email preview decryption and enhance ComposeMessageDialog with subject handling --- src/components/compose-message/index.tsx | 4 +- src/features/mail/MailView.tsx | 8 +- src/hooks/mail/useDecryptedMail.ts | 16 ++- src/hooks/mail/useDecryptedPreviews.ts | 51 +++++++++ src/i18n/locales/en.json | 1 - src/i18n/locales/es.json | 1 - src/i18n/locales/fr.json | 1 - src/i18n/locales/it.json | 1 - src/services/mail-encryption/index.test.ts | 121 ++++++++++++++++++--- 9 files changed, 168 insertions(+), 36 deletions(-) create mode 100644 src/hooks/mail/useDecryptedPreviews.ts diff --git a/src/components/compose-message/index.tsx b/src/components/compose-message/index.tsx index 3827bac..eb013dc 100644 --- a/src/components/compose-message/index.tsx +++ b/src/components/compose-message/index.tsx @@ -114,14 +114,14 @@ export const ComposeMessageDialog = () => { { address: senderKeys.address, publicKey: senderKeys.publicKey }, ]; const encryption = await buildEncryptionBlock( - { subject: subjectValue, text: htmlBody || textBody }, + { 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: translate('modals.composeMessageDialog.encryptedPlaceholderSubject'), + subject: subjectValue, encryption, }).unwrap(); } else { diff --git a/src/features/mail/MailView.tsx b/src/features/mail/MailView.tsx index df452c4..cdc5907 100644 --- a/src/features/mail/MailView.tsx +++ b/src/features/mail/MailView.tsx @@ -14,6 +14,7 @@ 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'; @@ -97,7 +98,8 @@ const MailView = ({ folder }: MailViewProps) => { } }; - const formattedMails = formatEmailsToList(listFolderEmails) ?? []; + const decryptedPreviews = useDecryptedPreviews(listFolderEmails); + const formattedMails = formatEmailsToList(listFolderEmails, decryptedPreviews) ?? []; return (
@@ -140,9 +142,9 @@ 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 }))} diff --git a/src/hooks/mail/useDecryptedMail.ts b/src/hooks/mail/useDecryptedMail.ts index 571e52d..ef71338 100644 --- a/src/hooks/mail/useDecryptedMail.ts +++ b/src/hooks/mail/useDecryptedMail.ts @@ -1,7 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import type { EmailResponse } from '@internxt/sdk/dist/mail/types'; import { useMailKeys } from './useMailKeys'; -import { useGetMailAccountKeysQuery } from '@/store/api/mail'; import { decryptEnvelope, isEncryptedEmailBody, parseEncryptionBlock } from '@/services/mail-encryption'; type State = { @@ -20,27 +19,26 @@ const EMPTY: State = { decryptError: false, }; -type CachedResult = { mailId: string; ok: true; subject: string; text: string } | { mailId: string; ok: false }; +type CachedResult = { mailId: string; ok: true; text: string } | { mailId: string; ok: false }; export const useDecryptedMail = (mail: EmailResponse | undefined): State => { const senderKeys = useMailKeys(); - const { data: account } = useGetMailAccountKeysQuery(); const isEncrypted = mail ? isEncryptedEmailBody(mail.textBody) : false; - const canDecrypt = Boolean(isEncrypted && senderKeys && account?.address); + const canDecrypt = Boolean(isEncrypted && senderKeys); const [cached, setCached] = useState(null); useEffect(() => { - if (!canDecrypt || !mail || !senderKeys || !account?.address) return; + if (!canDecrypt || !mail || !senderKeys) return; let cancelled = false; (async () => { try { const envelope = parseEncryptionBlock(mail.textBody as string); - const plaintext = await decryptEnvelope(envelope, account.address, senderKeys); + const text = await decryptEnvelope(envelope, senderKeys); if (!cancelled) { - setCached({ mailId: mail.id, ok: true, subject: plaintext.subject, text: plaintext.text }); + setCached({ mailId: mail.id, ok: true, text }); } } catch { if (!cancelled) setCached({ mailId: mail.id, ok: false }); @@ -50,7 +48,7 @@ export const useDecryptedMail = (mail: EmailResponse | undefined): State => { return () => { cancelled = true; }; - }, [canDecrypt, mail, senderKeys, account?.address]); + }, [canDecrypt, mail, senderKeys]); return useMemo(() => { if (!mail) return EMPTY; @@ -76,7 +74,7 @@ export const useDecryptedMail = (mail: EmailResponse | undefined): State => { } return { - subject: fresh.subject, + subject: mail.subject, htmlBody: fresh.text, isEncrypted: true, isDecrypting: false, diff --git a/src/hooks/mail/useDecryptedPreviews.ts b/src/hooks/mail/useDecryptedPreviews.ts new file mode 100644 index 0000000..1d92df3 --- /dev/null +++ b/src/hooks/mail/useDecryptedPreviews.ts @@ -0,0 +1,51 @@ +import { useEffect, useRef, useState } from 'react'; +import type { EmailListResponse } from '@internxt/sdk/dist/mail/types'; +import { useMailKeys } from './useMailKeys'; +import { decryptSummaryPreview } from '@/services/mail-encryption'; + +type Summary = EmailListResponse['emails'][number]; + +/** + * 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; + (async () => { + const resolved: Record = {}; + for (const summary of pending) { + try { + resolved[summary.id] = await decryptSummaryPreview(summary.encryption!, keypair); + } catch { + // Not decryptable + } + } + 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 87c03a3..3ce92ba 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -192,7 +192,6 @@ "message": "Message", "encryptedBadge": "End-to-end encrypted", "cleartextBadge": "Not encrypted", - "encryptedPlaceholderSubject": "Encrypted message", "errors": { "noRecipients": "Add at least one recipient before sending", "sendFailed": "Could not send the email", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 9617cc9..2881dcd 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -194,7 +194,6 @@ "message": "Mensaje", "encryptedBadge": "Cifrado de extremo a extremo", "cleartextBadge": "Sin cifrar", - "encryptedPlaceholderSubject": "Mensaje cifrado", "errors": { "noRecipients": "Agrega al menos un destinatario antes de enviar", "sendFailed": "No se pudo enviar el correo", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 229b739..65a10ef 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -194,7 +194,6 @@ "message": "Message", "encryptedBadge": "Chiffré de bout en bout", "cleartextBadge": "Non chiffré", - "encryptedPlaceholderSubject": "Message chiffré", "errors": { "noRecipients": "Ajoutez au moins un destinataire avant d'envoyer", "sendFailed": "Impossible d'envoyer le courriel", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 1954743..0d22502 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -194,7 +194,6 @@ "message": "Messaggio", "encryptedBadge": "Crittografia end-to-end", "cleartextBadge": "Non crittografato", - "encryptedPlaceholderSubject": "Messaggio crittografato", "errors": { "noRecipients": "Aggiungi almeno un destinatario prima di inviare", "sendFailed": "Impossibile inviare l'email", diff --git a/src/services/mail-encryption/index.test.ts b/src/services/mail-encryption/index.test.ts index dbaac34..233710d 100644 --- a/src/services/mail-encryption/index.test.ts +++ b/src/services/mail-encryption/index.test.ts @@ -4,54 +4,139 @@ import { ENCRYPTED_EMAIL_PREFIX, buildEncryptionBlock, decryptEnvelope, + decryptSummaryPreview, isEncryptedEmailBody, parseEncryptionBlock, type RecipientPublicKey, } from '.'; +const content = (body: string, previewText = body) => ({ body, previewText }); + describe('buildEncryptionBlock + decryptEnvelope', () => { - test('When a message is encrypted for one recipient, then that recipient can read the original content', async () => { + 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 buildEncryptionBlock({ subject: 'hello', text: '

hi bob

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

hi bob

'), recipients); expect(envelope.version).toBe('v1'); - expect(envelope.wrappedKeys['bob@inxt.me']).toBeDefined(); + expect(Array.isArray(envelope.wrappedKeys)).toBe(true); + expect(envelope.wrappedKeys).toHaveLength(1); + + const text = await 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 buildEncryptionBlock(content('body'), [ + { address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }, + ]); - const plaintext = await decryptEnvelope(envelope, 'bob@inxt.me', bob); - expect(plaintext.subject).toBe('hello'); - expect(plaintext.text).toBe('

hi bob

'); + expect(envelope).not.toHaveProperty('encryptedSubject'); }); - test('When a message is encrypted for multiple recipients, then each recipient can read the original content', async () => { + 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 buildEncryptionBlock({ subject: 'group hello', text: 'hey team' }, [ + const envelope = await buildEncryptionBlock(content('hey team'), [ { address: 'alice@inxt.me', publicKey: uint8ArrayToBase64(alice.publicKey) }, { address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }, ]); - const aliceView = await decryptEnvelope(envelope, 'alice@inxt.me', alice); - const bobView = await decryptEnvelope(envelope, 'bob@inxt.me', bob); + const aliceView = await decryptEnvelope(envelope, alice); + const bobView = await 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 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(aliceView.text).toBe('hey team'); - expect(bobView.text).toBe('hey team'); + expect(await decryptEnvelope(envelope, bcc)).toBe('hidden recipients'); + expect(await decryptEnvelope(envelope, sender)).toBe('hidden recipients'); + expect(await decryptEnvelope(envelope, to)).toBe('hidden recipients'); + expect(await decryptEnvelope(envelope, cc)).toBe('hidden recipients'); }); test('When no recipients are provided, then encryption should fail', async () => { - await expect(buildEncryptionBlock({ subject: 's', text: 't' }, [])).rejects.toThrow(); + await expect(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 buildEncryptionBlock(content('y'), [ + { address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }, + ]); + + await expect(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 buildEncryptionBlock({ body: '

full body

', previewText }, [ + { address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }, + ]); + + const preview = await 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 decrypting with an address that was not a recipient, then decryption should fail', async () => { + test('When a non-recipient tries to read the preview, then it fails cleanly', async () => { const bob = await generateEmailKeys(); - const envelope = await buildEncryptionBlock({ subject: 'x', text: 'y' }, [ + const eve = await generateEmailKeys(); + const envelope = await buildEncryptionBlock(content('

body

', 'snippet'), [ { address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }, ]); - await expect(decryptEnvelope(envelope, 'eve@inxt.me', bob)).rejects.toThrow(); + expect( + await decryptSummaryPreview( + { encryptedPreview: envelope.encryptedPreview, wrappedKeys: envelope.wrappedKeys }, + bob, + ), + ).toBe('snippet'); + await expect( + decryptSummaryPreview({ encryptedPreview: envelope.encryptedPreview, wrappedKeys: envelope.wrappedKeys }, eve), + ).rejects.toThrow(/not a recipient or wrong key/); }); }); @@ -66,9 +151,9 @@ describe('isEncryptedEmailBody / parseEncryptionBlock', () => { test('When the body contains a valid encrypted bundle, then it should parse the encryption block', () => { const block = { version: 'v1' as const, - encryptedSubject: 'es', encryptedText: 'et', - wrappedKeys: { 'a@inxt.me': { hybridCiphertext: 'h', encryptedKey: 'k' } }, + encryptedPreview: 'ep', + wrappedKeys: [{ hybridCiphertext: 'h', encryptedKey: 'k' }], }; const wire = `${ENCRYPTED_EMAIL_PREFIX}\n${Buffer.from(JSON.stringify(block)).toString('base64')}`; expect(parseEncryptionBlock(wire)).toStrictEqual(block); From c1b75440025aba720306c6dd9e33e61857ad9b00 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Wed, 27 May 2026 13:08:56 -0600 Subject: [PATCH 04/12] feat(mail): integrate decrypted previews in email formatting and enhance encryption handling --- .../tray/search/components/list/index.tsx | 4 +- src/services/mail-encryption/index.ts | 133 +++++++++++------- src/services/sdk/mail/mail.service.test.ts | 7 +- src/utils/format-emails/index.test.ts | 13 ++ src/utils/format-emails/index.ts | 7 +- 5 files changed, 103 insertions(+), 61 deletions(-) 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/services/mail-encryption/index.ts b/src/services/mail-encryption/index.ts index 6e1ffd1..a108eab 100644 --- a/src/services/mail-encryption/index.ts +++ b/src/services/mail-encryption/index.ts @@ -1,60 +1,83 @@ +import { base64ToUint8Array, type HybridKeyPair } from 'internxt-crypto'; import { - base64ToUint8Array, - decryptEmailHybrid, - encryptEmailHybridForMultipleRecipients, - type EmailBody, - type HybridEncryptedEmail, - type HybridKeyPair, -} from 'internxt-crypto'; + decryptEmailBody, + decryptKeysHybrid, + encryptEmailBody, + encryptEmailBodyWithKey, + encryptKeysHybrid, +} from 'internxt-crypto/email-crypto'; import type { EncryptionBlock } from '@internxt/sdk/dist/mail/types'; 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; + +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; +} /** - * Builds the on-the-wire encryption bundle for a send. All recipients share - * the same symmetric body ciphertext; each gets their own wrapped key. + * Only the body and preview are encrypted; the subject travels as cleartext so + * the backend can index it. * - * `publicKey` is the recipient's hybrid (X25519 + ML-KEM-768 / X-Wing) key, - * base64-encoded as returned by the backend lookup endpoint. + * 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`). */ export async function buildEncryptionBlock( - email: EmailBody, + content: EmailContent, recipients: RecipientPublicKey[], ): Promise { if (recipients.length === 0) { throw new Error('At least one recipient is required to build the encryption block'); } - const recipientsWithBytes = recipients.map((r) => ({ - email: r.address, - publicHybridKey: base64ToUint8Array(r.publicKey), - })); + const { encEmailBody, encryptionKey } = await encryptEmailBody({ text: content.body }); - const sealed = await encryptEmailHybridForMultipleRecipients(email, recipientsWithBytes); + const { encText: encryptedPreview } = await encryptEmailBodyWithKey( + { text: buildPreviewSnippet(content.previewText) }, + encryptionKey, + ); - const wrappedKeys: EncryptionBlock['wrappedKeys'] = {}; - for (const s of sealed) { - wrappedKeys[s.encryptedKey.encryptedForEmail] = { - hybridCiphertext: s.encryptedKey.hybridCiphertext, - encryptedKey: s.encryptedKey.encryptedKey, - }; - } + 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', - encryptedSubject: sealed[0].encEmailBody.encSubject, - encryptedText: sealed[0].encEmailBody.encText, + encryptedText: encEmailBody.encText, + encryptedPreview, wrappedKeys, }; } export const ENCRYPTED_EMAIL_PREFIX = 'INTERNXT-ENCRYPTED-EMAIL-v1'; -/** - * Detects the encryption envelope marker in a body. The backend wraps every - * encrypted send as `INTERNXT-ENCRYPTED-EMAIL-v1\n` (see - * `mail/src/modules/email/email.service.ts`). - */ export function isEncryptedEmailBody(textBody: string | null | undefined): boolean { if (!textBody) return false; return textBody.startsWith(`${ENCRYPTED_EMAIL_PREFIX}\n`); @@ -67,28 +90,32 @@ export function parseEncryptionBlock(textBody: string): EncryptionBlock { } /** - * Decrypts an envelope using the recipient's hybrid keypair. The recipient - * address must match one of the wrappedKeys entries. + * 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. */ -export async function decryptEnvelope( - envelope: EncryptionBlock, - recipientAddress: string, - keypair: HybridKeyPair, -): Promise { - const wrapped = envelope.wrappedKeys[recipientAddress]; - if (!wrapped) { - throw new Error(`No wrapped key found for ${recipientAddress}`); +async function 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 decryptEmailBody({ encText: ciphertextB64 }, bodyKey); + return text; + } catch { + // No op, try the next one. + } } - const sealed: HybridEncryptedEmail = { - encryptedKey: { - hybridCiphertext: wrapped.hybridCiphertext, - encryptedKey: wrapped.encryptedKey, - encryptedForEmail: recipientAddress, - }, - encEmailBody: { - encText: envelope.encryptedText, - encSubject: envelope.encryptedSubject, - }, - }; - return decryptEmailHybrid(sealed, keypair.secretKey); + throw new Error('Cannot decrypt envelope: not a recipient or wrong key'); +} + +export function decryptEnvelope(envelope: EncryptionBlock, keypair: HybridKeyPair): Promise { + return trialDecrypt(envelope.wrappedKeys, envelope.encryptedText, keypair); +} + +export function decryptSummaryPreview(summary: EncryptedSummary, keypair: HybridKeyPair): Promise { + return trialDecrypt(summary.wrappedKeys, summary.encryptedPreview, keypair); } diff --git a/src/services/sdk/mail/mail.service.test.ts b/src/services/sdk/mail/mail.service.test.ts index c08bf0f..c8bab62 100644 --- a/src/services/sdk/mail/mail.service.test.ts +++ b/src/services/sdk/mail/mail.service.test.ts @@ -321,14 +321,11 @@ describe('Mail Service', () => { test('When sending an encrypted email, then the encryption block should be forwarded', async () => { const payload = { to: [{ email: 'bob@inxt.me' }], - subject: 'Encrypted message', + subject: 'Weekly sync notes', encryption: { version: 'v1' as const, - encryptedSubject: 'enc-subj', encryptedText: 'enc-text', - wrappedKeys: { - 'bob@inxt.me': { hybridCiphertext: 'ct', encryptedKey: 'ek' }, - }, + wrappedKeys: [{ hybridCiphertext: 'ct', encryptedKey: 'ek' }], }, }; const mockMailClient = { 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, })); }; From 7b4f50d0933aaa661bdcca0d20aede92d2ac6d77 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Wed, 27 May 2026 13:20:43 -0600 Subject: [PATCH 05/12] chore: add unit tests for useDecryptedMail and useDecryptedPreviews hooks to validate email decryption logic --- src/hooks/mail/useDecryptedMail.test.tsx | 102 +++++++++++++++++++ src/hooks/mail/useDecryptedPreviews.test.tsx | 73 +++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 src/hooks/mail/useDecryptedMail.test.tsx create mode 100644 src/hooks/mail/useDecryptedPreviews.test.tsx diff --git a/src/hooks/mail/useDecryptedMail.test.tsx b/src/hooks/mail/useDecryptedMail.test.tsx new file mode 100644 index 0000000..01d0512 --- /dev/null +++ b/src/hooks/mail/useDecryptedMail.test.tsx @@ -0,0 +1,102 @@ +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 { decryptEnvelope, isEncryptedEmailBody, parseEncryptionBlock } from '@/services/mail-encryption'; + +vi.mock('./useMailKeys', () => ({ useMailKeys: vi.fn() })); +vi.mock('@/services/mail-encryption', () => ({ + isEncryptedEmailBody: vi.fn(), + parseEncryptionBlock: vi.fn(), + decryptEnvelope: vi.fn(), +})); + +const mockKeys = vi.mocked(useMailKeys); +const mockIsEncrypted = vi.mocked(isEncryptedEmailBody); +const mockParse = vi.mocked(parseEncryptionBlock); +const mockDecrypt = vi.mocked(decryptEnvelope); + +const keypair = {} as HybridKeyPair; +const envelope = { version: 'v1' } as 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.clearAllMocks(); + mockKeys.mockReturnValue(keypair); + mockParse.mockReturnValue(envelope); + }); + + test('When there is no mail, then it reports an empty, non-encrypted state', () => { + mockIsEncrypted.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', () => { + mockIsEncrypted.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(mockDecrypt).not.toHaveBeenCalled(); + }); + + test('When an encrypted mail is decrypted successfully, then it returns the decrypted body and clears the decrypting state', async () => { + mockIsEncrypted.mockReturnValue(true); + mockDecrypt.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 () => { + mockIsEncrypted.mockReturnValue(true); + mockDecrypt.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', () => { + mockIsEncrypted.mockReturnValue(true); + mockDecrypt.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/useDecryptedPreviews.test.tsx b/src/hooks/mail/useDecryptedPreviews.test.tsx new file mode 100644 index 0000000..f2b2416 --- /dev/null +++ b/src/hooks/mail/useDecryptedPreviews.test.tsx @@ -0,0 +1,73 @@ +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 { decryptSummaryPreview } from '@/services/mail-encryption'; + +vi.mock('./useMailKeys', () => ({ useMailKeys: vi.fn() })); +vi.mock('@/services/mail-encryption', () => ({ decryptSummaryPreview: vi.fn() })); + +const mockKeys = vi.mocked(useMailKeys); +const mockDecrypt = vi.mocked(decryptSummaryPreview); + +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; + +describe('useDecryptedPreviews', () => { + beforeEach(() => { + vi.clearAllMocks(); + 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); + }); +}); From 1df2887f7b5ec998220779ee750297ce7dd6e698 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Mon, 18 May 2026 16:09:28 -0600 Subject: [PATCH 06/12] feat(recipient-keys): implement RecipientKeysService for managing public keys and add lookup functionality in MailService --- src/store/api/mail/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/store/api/mail/index.ts b/src/store/api/mail/index.ts index ca4f1f1..948a630 100644 --- a/src/store/api/mail/index.ts +++ b/src/store/api/mail/index.ts @@ -250,7 +250,7 @@ export const mailApi = api.injectEndpoints({ serializeQueryArgs: ({ queryArgs }) => ({ addresses: [...queryArgs.addresses] .map((a) => a.toLowerCase()) - .sort() + .sort((a, b) => a.localeCompare(b)) .join(','), }), async queryFn({ addresses }): Promise<{ data: RecipientKey[] } | { error: FetchRecipientKeysError }> { From 0cc0eb28b494958fef37e120bd4b3ae82fe1c0c1 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Thu, 28 May 2026 11:17:08 +0200 Subject: [PATCH 07/12] chore(deps): bump SDK version --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 04d00e4..910decc 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", @@ -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" diff --git a/package.json b/package.json index 19cf4a8..8bc9afc 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", From 31ee18f5c8977178d9c8a999e353a433b493260b Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Thu, 28 May 2026 08:56:14 -0600 Subject: [PATCH 08/12] feat(mail): refactor email decryption logic in useDecryptedMail and useDecryptedPreviews hooks for improved readability and error handling --- src/hooks/mail/useDecryptedMail.ts | 26 +++++++++++++---------- src/hooks/mail/useDecryptedPreviews.ts | 25 +++++++++++++--------- src/services/recipient-keys/index.test.ts | 8 +++---- 3 files changed, 34 insertions(+), 25 deletions(-) diff --git a/src/hooks/mail/useDecryptedMail.ts b/src/hooks/mail/useDecryptedMail.ts index ef71338..eb5df19 100644 --- a/src/hooks/mail/useDecryptedMail.ts +++ b/src/hooks/mail/useDecryptedMail.ts @@ -1,5 +1,6 @@ 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 { decryptEnvelope, isEncryptedEmailBody, parseEncryptionBlock } from '@/services/mail-encryption'; @@ -21,6 +22,17 @@ const EMPTY: State = { type CachedResult = { mailId: string; ok: true; text: string } | { mailId: string; ok: false }; +const decryptMailBody = async (mail: EmailResponse, senderKeys: HybridKeyPair): Promise => { + try { + const envelope = parseEncryptionBlock(mail.textBody as string); + const text = await decryptEnvelope(envelope, senderKeys); + return { mailId: mail.id, ok: true, text }; + } catch (error) { + console.error('Failed to decrypt mail body', error); + return { mailId: mail.id, ok: false }; + } +}; + export const useDecryptedMail = (mail: EmailResponse | undefined): State => { const senderKeys = useMailKeys(); @@ -33,17 +45,9 @@ export const useDecryptedMail = (mail: EmailResponse | undefined): State => { if (!canDecrypt || !mail || !senderKeys) return; let cancelled = false; - (async () => { - try { - const envelope = parseEncryptionBlock(mail.textBody as string); - const text = await decryptEnvelope(envelope, senderKeys); - if (!cancelled) { - setCached({ mailId: mail.id, ok: true, text }); - } - } catch { - if (!cancelled) setCached({ mailId: mail.id, ok: false }); - } - })(); + decryptMailBody(mail, senderKeys).then((result) => { + if (!cancelled) setCached(result); + }); return () => { cancelled = true; diff --git a/src/hooks/mail/useDecryptedPreviews.ts b/src/hooks/mail/useDecryptedPreviews.ts index 1d92df3..60000ff 100644 --- a/src/hooks/mail/useDecryptedPreviews.ts +++ b/src/hooks/mail/useDecryptedPreviews.ts @@ -1,10 +1,23 @@ 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 { decryptSummaryPreview } 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 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 }) @@ -28,19 +41,11 @@ export const useDecryptedPreviews = (summaries: Summary[] | undefined): Record attempted.current.add(s.id)); let cancelled = false; - (async () => { - const resolved: Record = {}; - for (const summary of pending) { - try { - resolved[summary.id] = await decryptSummaryPreview(summary.encryption!, keypair); - } catch { - // Not decryptable - } - } + decryptPendingPreviews(pending, keypair).then((resolved) => { if (!cancelled && Object.keys(resolved).length) { setPreviews((prev) => ({ ...prev, ...resolved })); } - })(); + }); return () => { cancelled = true; diff --git a/src/services/recipient-keys/index.test.ts b/src/services/recipient-keys/index.test.ts index e352d8c..29e8c2c 100644 --- a/src/services/recipient-keys/index.test.ts +++ b/src/services/recipient-keys/index.test.ts @@ -12,23 +12,23 @@ describe('RecipientKeysService', () => { vi.useRealTimers(); }); - test('stores and retrieves a public key', () => { + 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('matches addresses case-insensitively', () => { + 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('returns null after the TTL has passed', () => { + 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('clear() empties the cache', () => { + 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(); From da9b73c64d4ceeae67a013aef2205c8c4927bf80 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Thu, 28 May 2026 09:55:09 -0600 Subject: [PATCH 09/12] chore: update sdk and crypto --- package-lock.json | 75 +++++++++++----------- package.json | 4 +- src/services/mail-encryption/index.ts | 14 ++-- src/services/sdk/mail/mail.service.test.ts | 1 + 4 files changed, 49 insertions(+), 45 deletions(-) diff --git a/package-lock.json b/package-lock.json index 910decc..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.17.2", + "@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", @@ -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 8bc9afc..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.17.2", + "@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/services/mail-encryption/index.ts b/src/services/mail-encryption/index.ts index a108eab..42d3d12 100644 --- a/src/services/mail-encryption/index.ts +++ b/src/services/mail-encryption/index.ts @@ -1,9 +1,9 @@ import { base64ToUint8Array, type HybridKeyPair } from 'internxt-crypto'; import { - decryptEmailBody, + decryptEmail, decryptKeysHybrid, - encryptEmailBody, - encryptEmailBodyWithKey, + encryptEmail, + encryptEmailWithKey, encryptKeysHybrid, } from 'internxt-crypto/email-crypto'; import type { EncryptionBlock } from '@internxt/sdk/dist/mail/types'; @@ -50,9 +50,9 @@ export async function buildEncryptionBlock( throw new Error('At least one recipient is required to build the encryption block'); } - const { encEmailBody, encryptionKey } = await encryptEmailBody({ text: content.body }); + const { encEmail, encryptionKey } = await encryptEmail({ text: content.body }); - const { encText: encryptedPreview } = await encryptEmailBodyWithKey( + const { encText: encryptedPreview } = await encryptEmailWithKey( { text: buildPreviewSnippet(content.previewText) }, encryptionKey, ); @@ -70,7 +70,7 @@ export async function buildEncryptionBlock( return { version: 'v1', - encryptedText: encEmailBody.encText, + encryptedText: encEmail.encText, encryptedPreview, wrappedKeys, }; @@ -103,7 +103,7 @@ async function trialDecrypt(wrappedKeys: WrappedKey[], ciphertextB64: string, ke { hybridCiphertext: wrapped.hybridCiphertext, encryptedKey: wrapped.encryptedKey, encryptedForEmail: '' }, keypair.secretKey, ); - const { text } = await decryptEmailBody({ encText: ciphertextB64 }, bodyKey); + const { text } = await decryptEmail({ encText: ciphertextB64 }, bodyKey); return text; } catch { // No op, try the next one. diff --git a/src/services/sdk/mail/mail.service.test.ts b/src/services/sdk/mail/mail.service.test.ts index c8bab62..c2eb309 100644 --- a/src/services/sdk/mail/mail.service.test.ts +++ b/src/services/sdk/mail/mail.service.test.ts @@ -325,6 +325,7 @@ describe('Mail Service', () => { encryption: { version: 'v1' as const, encryptedText: 'enc-text', + encryptedPreview: 'enc-preview', wrappedKeys: [{ hybridCiphertext: 'ct', encryptedKey: 'ek' }], }, }; From a79dcd0fc96bccf88a9e5dd6ecff106a42d1f9c0 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Thu, 28 May 2026 11:41:48 -0600 Subject: [PATCH 10/12] chore: refactor email encryption handling by introducing MailEncryptionService for improved modularity and error handling in ComposeMessageDialog and related hooks --- src/components/compose-message/index.tsx | 51 +++--- src/hooks/mail/useDecryptedMail.test.tsx | 15 +- src/hooks/mail/useDecryptedMail.ts | 23 +-- src/hooks/mail/useDecryptedPreviews.test.tsx | 5 +- src/hooks/mail/useDecryptedPreviews.ts | 4 +- src/services/mail-encryption/index.test.ts | 62 ++++---- src/services/mail-encryption/index.ts | 157 ++++++++++--------- src/store/api/mail/index.ts | 21 ++- src/store/api/mail/mail.api.test.ts | 33 ++++ 9 files changed, 212 insertions(+), 159 deletions(-) diff --git a/src/components/compose-message/index.tsx b/src/components/compose-message/index.tsx index eb013dc..3a12787 100644 --- a/src/components/compose-message/index.tsx +++ b/src/components/compose-message/index.tsx @@ -17,7 +17,7 @@ import { useSendEmailMutation, } from '@/store/api/mail'; import { classifyRecipients, uniqueEmailAddresses } from '@/utils/domain'; -import { buildEncryptionBlock, type RecipientPublicKey } from '@/services/mail-encryption'; +import { MailEncryptionService, type RecipientPublicKey } from '@/services/mail-encryption'; import notificationsService, { ToastType } from '@/services/notifications'; import type { EmailAddress, SendEmailRequest } from '@internxt/sdk/dist/mail/types'; @@ -103,30 +103,41 @@ export const ComposeMessageDialog = () => { }; try { - if (encryptionState === 'encrypted' && senderKeys?.address && senderKeys.publicKey) { + if (encryptionState === 'encrypted') { + if (!senderKeys?.address || !senderKeys.publicKey) { + notificationsService.show({ + text: translate('modals.composeMessageDialog.errors.keyLookupFailed'), + type: ToastType.Error, + }); + return; + } 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 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, - }).unwrap(); - } else { - await sendEmail(cleartextPayload).unwrap(); + if (usable.length !== uniqueAddresses.length) { + notificationsService.show({ + text: translate('modals.composeMessageDialog.errors.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: subjectValue, + encryption, + }).unwrap(); } else { await sendEmail(cleartextPayload).unwrap(); } diff --git a/src/hooks/mail/useDecryptedMail.test.tsx b/src/hooks/mail/useDecryptedMail.test.tsx index 01d0512..6338846 100644 --- a/src/hooks/mail/useDecryptedMail.test.tsx +++ b/src/hooks/mail/useDecryptedMail.test.tsx @@ -4,22 +4,17 @@ import type { HybridKeyPair } from 'internxt-crypto'; import type { EmailResponse } from '@internxt/sdk/dist/mail/types'; import { useDecryptedMail } from './useDecryptedMail'; import { useMailKeys } from './useMailKeys'; -import { decryptEnvelope, isEncryptedEmailBody, parseEncryptionBlock } from '@/services/mail-encryption'; +import { MailEncryptionService } from '@/services/mail-encryption'; vi.mock('./useMailKeys', () => ({ useMailKeys: vi.fn() })); -vi.mock('@/services/mail-encryption', () => ({ - isEncryptedEmailBody: vi.fn(), - parseEncryptionBlock: vi.fn(), - decryptEnvelope: vi.fn(), -})); const mockKeys = vi.mocked(useMailKeys); -const mockIsEncrypted = vi.mocked(isEncryptedEmailBody); -const mockParse = vi.mocked(parseEncryptionBlock); -const mockDecrypt = vi.mocked(decryptEnvelope); +const mockIsEncrypted = vi.spyOn(MailEncryptionService.instance, 'isEncryptedEmailBody'); +const mockParse = vi.spyOn(MailEncryptionService.instance, 'parseEncryptionBlock'); +const mockDecrypt = vi.spyOn(MailEncryptionService.instance, 'decryptEnvelope'); const keypair = {} as HybridKeyPair; -const envelope = { version: 'v1' } as ReturnType; +const envelope = { version: 'v1' } as ReturnType; const buildMail = (overrides: Partial = {}): EmailResponse => ({ diff --git a/src/hooks/mail/useDecryptedMail.ts b/src/hooks/mail/useDecryptedMail.ts index eb5df19..5cda6a4 100644 --- a/src/hooks/mail/useDecryptedMail.ts +++ b/src/hooks/mail/useDecryptedMail.ts @@ -2,7 +2,7 @@ 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 { decryptEnvelope, isEncryptedEmailBody, parseEncryptionBlock } from '@/services/mail-encryption'; +import { MailEncryptionService } from '@/services/mail-encryption'; type State = { subject: string; @@ -20,39 +20,40 @@ const EMPTY: State = { decryptError: false, }; -type CachedResult = { mailId: string; ok: true; text: string } | { mailId: string; ok: false }; +type CachedResult = { ok: true; text: string } | { ok: false }; const decryptMailBody = async (mail: EmailResponse, senderKeys: HybridKeyPair): Promise => { try { - const envelope = parseEncryptionBlock(mail.textBody as string); - const text = await decryptEnvelope(envelope, senderKeys); - return { mailId: mail.id, ok: true, text }; + 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 { mailId: mail.id, ok: false }; + return { ok: false }; } }; export const useDecryptedMail = (mail: EmailResponse | undefined): State => { const senderKeys = useMailKeys(); - const isEncrypted = mail ? isEncryptedEmailBody(mail.textBody) : false; + const isEncrypted = mail ? MailEncryptionService.instance.isEncryptedEmailBody(mail.textBody) : false; const canDecrypt = Boolean(isEncrypted && senderKeys); - const [cached, setCached] = useState(null); + 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(result); + if (!cancelled) setCached((prev) => ({ ...prev, [mail.id]: result })); }); return () => { cancelled = true; }; - }, [canDecrypt, mail, senderKeys]); + }, [canDecrypt, mail, senderKeys, cached]); return useMemo(() => { if (!mail) return EMPTY; @@ -67,7 +68,7 @@ export const useDecryptedMail = (mail: EmailResponse | undefined): State => { }; } - const fresh = cached && cached.mailId === mail.id ? cached : null; + const fresh = cached[mail.id] ?? null; if (!fresh) { return { subject: mail.subject, htmlBody: '', isEncrypted: true, isDecrypting: true, decryptError: false }; diff --git a/src/hooks/mail/useDecryptedPreviews.test.tsx b/src/hooks/mail/useDecryptedPreviews.test.tsx index f2b2416..0e52fa5 100644 --- a/src/hooks/mail/useDecryptedPreviews.test.tsx +++ b/src/hooks/mail/useDecryptedPreviews.test.tsx @@ -4,13 +4,12 @@ import type { HybridKeyPair } from 'internxt-crypto'; import type { EmailListResponse } from '@internxt/sdk/dist/mail/types'; import { useDecryptedPreviews } from './useDecryptedPreviews'; import { useMailKeys } from './useMailKeys'; -import { decryptSummaryPreview } from '@/services/mail-encryption'; +import { MailEncryptionService } from '@/services/mail-encryption'; vi.mock('./useMailKeys', () => ({ useMailKeys: vi.fn() })); -vi.mock('@/services/mail-encryption', () => ({ decryptSummaryPreview: vi.fn() })); const mockKeys = vi.mocked(useMailKeys); -const mockDecrypt = vi.mocked(decryptSummaryPreview); +const mockDecrypt = vi.spyOn(MailEncryptionService.instance, 'decryptSummaryPreview'); type Summary = EmailListResponse['emails'][number]; diff --git a/src/hooks/mail/useDecryptedPreviews.ts b/src/hooks/mail/useDecryptedPreviews.ts index 60000ff..2245d53 100644 --- a/src/hooks/mail/useDecryptedPreviews.ts +++ b/src/hooks/mail/useDecryptedPreviews.ts @@ -2,7 +2,7 @@ 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 { decryptSummaryPreview } from '@/services/mail-encryption'; +import { MailEncryptionService } from '@/services/mail-encryption'; type Summary = EmailListResponse['emails'][number]; @@ -10,7 +10,7 @@ const decryptPendingPreviews = async (pending: Summary[], keypair: HybridKeyPair const resolved: Record = {}; for (const summary of pending) { try { - resolved[summary.id] = await decryptSummaryPreview(summary.encryption!, keypair); + resolved[summary.id] = await MailEncryptionService.instance.decryptSummaryPreview(summary.encryption!, keypair); } catch (error) { console.error('Failed to decrypt mail preview', { mailId: summary.id, error }); } diff --git a/src/services/mail-encryption/index.test.ts b/src/services/mail-encryption/index.test.ts index 233710d..dd12bf9 100644 --- a/src/services/mail-encryption/index.test.ts +++ b/src/services/mail-encryption/index.test.ts @@ -1,15 +1,8 @@ import { describe, expect, test } from 'vitest'; import { generateEmailKeys, uint8ArrayToBase64 } from 'internxt-crypto'; -import { - ENCRYPTED_EMAIL_PREFIX, - buildEncryptionBlock, - decryptEnvelope, - decryptSummaryPreview, - isEncryptedEmailBody, - parseEncryptionBlock, - type RecipientPublicKey, -} from '.'; +import { ENCRYPTED_EMAIL_PREFIX, MailEncryptionService, type RecipientPublicKey } from '.'; +const mailEncryption = MailEncryptionService.instance; const content = (body: string, previewText = body) => ({ body, previewText }); describe('buildEncryptionBlock + decryptEnvelope', () => { @@ -18,20 +11,20 @@ describe('buildEncryptionBlock + decryptEnvelope', () => { const recipients: RecipientPublicKey[] = [{ address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }]; - const envelope = await buildEncryptionBlock(content('

hi bob

'), recipients); + 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 decryptEnvelope(envelope, bob); + 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 buildEncryptionBlock(content('body'), [ + const envelope = await mailEncryption.buildEncryptionBlock(content('body'), [ { address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }, ]); @@ -42,13 +35,13 @@ describe('buildEncryptionBlock + decryptEnvelope', () => { const alice = await generateEmailKeys(); const bob = await generateEmailKeys(); - const envelope = await buildEncryptionBlock(content('hey team'), [ + 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 decryptEnvelope(envelope, alice); - const bobView = await decryptEnvelope(envelope, bob); + const aliceView = await mailEncryption.decryptEnvelope(envelope, alice); + const bobView = await mailEncryption.decryptEnvelope(envelope, bob); expect(aliceView).toBe('hey team'); expect(bobView).toBe('hey team'); @@ -61,7 +54,7 @@ describe('buildEncryptionBlock + decryptEnvelope', () => { const bcc = await generateEmailKeys(); const addresses = ['sender@inxt.me', 'to@inxt.me', 'cc@inxt.me', 'secret-bcc@inxt.me']; - const envelope = await buildEncryptionBlock(content('hidden recipients'), [ + 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) }, @@ -81,24 +74,24 @@ describe('buildEncryptionBlock + decryptEnvelope', () => { expect(Object.keys(entry).sort()).toStrictEqual(['encryptedKey', 'hybridCiphertext']); } - expect(await decryptEnvelope(envelope, bcc)).toBe('hidden recipients'); - expect(await decryptEnvelope(envelope, sender)).toBe('hidden recipients'); - expect(await decryptEnvelope(envelope, to)).toBe('hidden recipients'); - expect(await decryptEnvelope(envelope, cc)).toBe('hidden recipients'); + 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(buildEncryptionBlock(content('t'), [])).rejects.toThrow(); + 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 buildEncryptionBlock(content('y'), [ + const envelope = await mailEncryption.buildEncryptionBlock(content('y'), [ { address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }, ]); - await expect(decryptEnvelope(envelope, eve)).rejects.toThrow(/not a recipient or wrong key/); + await expect(mailEncryption.decryptEnvelope(envelope, eve)).rejects.toThrow(/not a recipient or wrong key/); }); }); @@ -107,11 +100,11 @@ describe('encrypted preview', () => { const bob = await generateEmailKeys(); const previewText = `First line.\n\n Second line with spaces.${' tail'.repeat(200)}`; - const envelope = await buildEncryptionBlock({ body: '

full body

', previewText }, [ + const envelope = await mailEncryption.buildEncryptionBlock({ body: '

full body

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

body

', 'snippet'), [ + const envelope = await mailEncryption.buildEncryptionBlock(content('

body

', 'snippet'), [ { address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }, ]); expect( - await decryptSummaryPreview( + await mailEncryption.decryptSummaryPreview( { encryptedPreview: envelope.encryptedPreview, wrappedKeys: envelope.wrappedKeys }, bob, ), ).toBe('snippet'); await expect( - decryptSummaryPreview({ encryptedPreview: envelope.encryptedPreview, wrappedKeys: envelope.wrappedKeys }, eve), + 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(isEncryptedEmailBody(`${ENCRYPTED_EMAIL_PREFIX}\nabc`)).toBe(true); - expect(isEncryptedEmailBody('plain body')).toBe(false); - expect(isEncryptedEmailBody(null)).toBe(false); - expect(isEncryptedEmailBody(undefined)).toBe(false); + 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', () => { @@ -156,6 +152,6 @@ describe('isEncryptedEmailBody / parseEncryptionBlock', () => { wrappedKeys: [{ hybridCiphertext: 'h', encryptedKey: 'k' }], }; const wire = `${ENCRYPTED_EMAIL_PREFIX}\n${Buffer.from(JSON.stringify(block)).toString('base64')}`; - expect(parseEncryptionBlock(wire)).toStrictEqual(block); + expect(mailEncryption.parseEncryptionBlock(wire)).toStrictEqual(block); }); }); diff --git a/src/services/mail-encryption/index.ts b/src/services/mail-encryption/index.ts index 42d3d12..750201e 100644 --- a/src/services/mail-encryption/index.ts +++ b/src/services/mail-encryption/index.ts @@ -14,6 +14,8 @@ 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); } @@ -34,88 +36,93 @@ function secureShuffle(items: T[]): T[] { return items; } -/** - * 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`). - */ -export async function buildEncryptionBlock( - content: EmailContent, - recipients: RecipientPublicKey[], -): Promise { - if (recipients.length === 0) { - throw new Error('At least one recipient is required to build the encryption block'); - } +export class MailEncryptionService { + public static readonly instance: MailEncryptionService = new MailEncryptionService(); - 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, - }; -} + private constructor() {} -export const ENCRYPTED_EMAIL_PREFIX = 'INTERNXT-ENCRYPTED-EMAIL-v1'; + /** + * 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 Error('At least one recipient is required to build the encryption block'); + } -export function isEncryptedEmailBody(textBody: string | null | undefined): boolean { - if (!textBody) return false; - return textBody.startsWith(`${ENCRYPTED_EMAIL_PREFIX}\n`); -} + const { encEmail, encryptionKey } = await encryptEmail({ text: content.body }); -export function 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; -} + const { encText: encryptedPreview } = await encryptEmailWithKey( + { text: buildPreviewSnippet(content.previewText) }, + encryptionKey, + ); -/** - * 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. - */ -async function 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. + 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 Error('Cannot decrypt envelope: not a recipient or wrong key'); } - throw new Error('Cannot decrypt envelope: not a recipient or wrong key'); -} -export function decryptEnvelope(envelope: EncryptionBlock, keypair: HybridKeyPair): Promise { - return trialDecrypt(envelope.wrappedKeys, envelope.encryptedText, keypair); -} + decryptEnvelope(envelope: EncryptionBlock, keypair: HybridKeyPair): Promise { + return this.trialDecrypt(envelope.wrappedKeys, envelope.encryptedText, keypair); + } -export function decryptSummaryPreview(summary: EncryptedSummary, keypair: HybridKeyPair): Promise { - return trialDecrypt(summary.wrappedKeys, summary.encryptedPreview, keypair); + decryptSummaryPreview(summary: EncryptedSummary, keypair: HybridKeyPair): Promise { + return this.trialDecrypt(summary.wrappedKeys, summary.encryptedPreview, keypair); + } } diff --git a/src/store/api/mail/index.ts b/src/store/api/mail/index.ts index 948a630..8a5dd00 100644 --- a/src/store/api/mail/index.ts +++ b/src/store/api/mail/index.ts @@ -32,6 +32,18 @@ import type { } 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, @@ -248,14 +260,13 @@ export const mailApi = api.injectEndpoints({ }), lookupRecipientKeys: builder.query({ serializeQueryArgs: ({ queryArgs }) => ({ - addresses: [...queryArgs.addresses] - .map((a) => a.toLowerCase()) - .sort((a, b) => a.localeCompare(b)) - .join(','), + 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(addresses); + const res: LookupRecipientKeysResponse = await MailService.instance.lookupRecipientKeys(normalized); for (const r of res.recipients) { if (r.publicKey) RecipientKeysService.instance.set(r.address, r.publicKey); } diff --git a/src/store/api/mail/mail.api.test.ts b/src/store/api/mail/mail.api.test.ts index ebac1ad..f589ff7 100644 --- a/src/store/api/mail/mail.api.test.ts +++ b/src/store/api/mail/mail.api.test.ts @@ -617,6 +617,39 @@ describe('Mail API', () => { 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', () => { From ef5cb1788ddb4f2a224b2344b6cc738b7a0d211e Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Thu, 28 May 2026 13:55:56 -0600 Subject: [PATCH 11/12] feat(mail): refactor ComposeMessageDialog and introduce useComposeSend hook for streamlined email sending and improved encryption handling --- .../compose-message/hooks/useComposeSend.ts | 155 ++++++++++++++++++ src/components/compose-message/index.tsx | 120 +------------- src/errors/mail/index.ts | 16 ++ src/hooks/mail/useDecryptedMail.test.tsx | 34 ++-- src/hooks/mail/useDecryptedPreviews.test.tsx | 7 +- src/i18n/locales/en.json | 12 +- src/i18n/locales/es.json | 12 +- src/i18n/locales/fr.json | 12 +- src/i18n/locales/it.json | 12 +- src/services/mail-encryption/index.test.ts | 6 +- src/services/mail-encryption/index.ts | 5 +- src/services/recipient-keys/index.test.ts | 17 ++ src/services/recipient-keys/index.ts | 6 +- src/store/api/mail/index.ts | 2 +- src/store/api/mail/mail.api.test.ts | 5 +- 15 files changed, 256 insertions(+), 165 deletions(-) create mode 100644 src/components/compose-message/hooks/useComposeSend.ts diff --git a/src/components/compose-message/hooks/useComposeSend.ts b/src/components/compose-message/hooks/useComposeSend.ts new file mode 100644 index 0000000..ef40281 --- /dev/null +++ b/src/components/compose-message/hooks/useComposeSend.ts @@ -0,0 +1,155 @@ +import { useCallback, useMemo } from 'react'; +import type { Editor } from '@tiptap/react'; +import type { EmailAddress, 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' | '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 'none'; + 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; + } + + 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)); + 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) { + 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 3a12787..70f5311 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 } from 'react'; +import { useCallback } from 'react'; import type { Recipient } from './types'; import { RecipientInput } from './components/RecipientInput'; import { Button, Input } from '@internxt/ui'; @@ -8,18 +8,9 @@ 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'; -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; @@ -29,8 +20,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(); @@ -57,111 +46,18 @@ 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 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(() => { onComposeMessageDialogClose(ActionDialog.ComposeMessage); }, [onComposeMessageDialogClose]); - const handlePrimaryAction = useCallback(async () => { - if (allRecipients.length === 0) { - notificationsService.show({ - text: translate('modals.composeMessageDialog.errors.noRecipients'), - type: ToastType.Warning, - }); - 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: subjectValue, - textBody: textBody || undefined, - htmlBody: htmlBody || undefined, - }; - - try { - if (encryptionState === 'encrypted') { - if (!senderKeys?.address || !senderKeys.publicKey) { - notificationsService.show({ - text: translate('modals.composeMessageDialog.errors.keyLookupFailed'), - type: ToastType.Error, - }); - return; - } - 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) { - notificationsService.show({ - text: translate('modals.composeMessageDialog.errors.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: subjectValue, - encryption, - }).unwrap(); - } else { - await sendEmail(cleartextPayload).unwrap(); - } - onClose(); - } catch { - notificationsService.show({ - text: translate('modals.composeMessageDialog.errors.sendFailed'), - type: ToastType.Error, - }); - } - }, [ - allRecipients, - editor, + const { encryptionState, isSending, send } = useComposeSend({ toRecipients, ccRecipients, bccRecipients, - subjectValue, - encryptionState, - senderKeys, - triggerLookup, - sendEmail, - onClose, - translate, - ]); + subject: subjectValue, + editor, + onSent: onClose, + }); if (!editor) return null; @@ -264,7 +160,7 @@ export const ComposeMessageDialog = () => { -
diff --git a/src/errors/mail/index.ts b/src/errors/mail/index.ts index 054ec78..3432e72 100644 --- a/src/errors/mail/index.ts +++ b/src/errors/mail/index.ts @@ -118,3 +118,19 @@ export class FetchActiveDomainsError extends Error { 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/hooks/mail/useDecryptedMail.test.tsx b/src/hooks/mail/useDecryptedMail.test.tsx index 6338846..39213a2 100644 --- a/src/hooks/mail/useDecryptedMail.test.tsx +++ b/src/hooks/mail/useDecryptedMail.test.tsx @@ -9,13 +9,18 @@ import { MailEncryptionService } from '@/services/mail-encryption'; vi.mock('./useMailKeys', () => ({ useMailKeys: vi.fn() })); const mockKeys = vi.mocked(useMailKeys); -const mockIsEncrypted = vi.spyOn(MailEncryptionService.instance, 'isEncryptedEmailBody'); -const mockParse = vi.spyOn(MailEncryptionService.instance, 'parseEncryptionBlock'); -const mockDecrypt = vi.spyOn(MailEncryptionService.instance, 'decryptEnvelope'); 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', @@ -27,13 +32,14 @@ const buildMail = (overrides: Partial = {}): EmailResponse => describe('useDecryptedMail', () => { beforeEach(() => { - vi.clearAllMocks(); + vi.restoreAllMocks(); + mailEncryption = spyOnEncryption(); mockKeys.mockReturnValue(keypair); - mockParse.mockReturnValue(envelope); + mailEncryption.parse.mockReturnValue(envelope); }); test('When there is no mail, then it reports an empty, non-encrypted state', () => { - mockIsEncrypted.mockReturnValue(false); + mailEncryption.isEncrypted.mockReturnValue(false); const { result } = renderHook(() => useDecryptedMail(undefined)); @@ -47,7 +53,7 @@ describe('useDecryptedMail', () => { }); test('When the mail is not encrypted, then it returns the original content without decrypting', () => { - mockIsEncrypted.mockReturnValue(false); + mailEncryption.isEncrypted.mockReturnValue(false); const mail = buildMail(); const { result } = renderHook(() => useDecryptedMail(mail)); @@ -55,12 +61,12 @@ describe('useDecryptedMail', () => { expect(result.current.subject).toBe('Weekly sync notes'); expect(result.current.htmlBody).toBe('

plain html

'); expect(result.current.isEncrypted).toBe(false); - expect(mockDecrypt).not.toHaveBeenCalled(); + 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 () => { - mockIsEncrypted.mockReturnValue(true); - mockDecrypt.mockResolvedValue('the secret body'); + mailEncryption.isEncrypted.mockReturnValue(true); + mailEncryption.decrypt.mockResolvedValue('the secret body'); const mail = buildMail(); const { result } = renderHook(() => useDecryptedMail(mail)); @@ -72,8 +78,8 @@ describe('useDecryptedMail', () => { }); test('When an encrypted mail cannot be decrypted, then it surfaces a decrypt error', async () => { - mockIsEncrypted.mockReturnValue(true); - mockDecrypt.mockRejectedValue(new Error('not a recipient')); + mailEncryption.isEncrypted.mockReturnValue(true); + mailEncryption.decrypt.mockRejectedValue(new Error('not a recipient')); const mail = buildMail(); const { result } = renderHook(() => useDecryptedMail(mail)); @@ -84,8 +90,8 @@ describe('useDecryptedMail', () => { }); test('While an encrypted mail is being decrypted, then it reports the decrypting state with the cleartext subject', () => { - mockIsEncrypted.mockReturnValue(true); - mockDecrypt.mockReturnValue(new Promise(() => undefined)); + mailEncryption.isEncrypted.mockReturnValue(true); + mailEncryption.decrypt.mockReturnValue(new Promise(() => undefined)); const mail = buildMail(); const { result } = renderHook(() => useDecryptedMail(mail)); diff --git a/src/hooks/mail/useDecryptedPreviews.test.tsx b/src/hooks/mail/useDecryptedPreviews.test.tsx index 0e52fa5..26364d5 100644 --- a/src/hooks/mail/useDecryptedPreviews.test.tsx +++ b/src/hooks/mail/useDecryptedPreviews.test.tsx @@ -9,7 +9,6 @@ import { MailEncryptionService } from '@/services/mail-encryption'; vi.mock('./useMailKeys', () => ({ useMailKeys: vi.fn() })); const mockKeys = vi.mocked(useMailKeys); -const mockDecrypt = vi.spyOn(MailEncryptionService.instance, 'decryptSummaryPreview'); type Summary = EmailListResponse['emails'][number]; @@ -18,9 +17,13 @@ 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.clearAllMocks(); + vi.restoreAllMocks(); + mockDecrypt = spyOnPreviewDecrypt(); mockKeys.mockReturnValue(keypair); }); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 3ce92ba..39d1942 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -150,7 +150,10 @@ "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" } }, "modals": { @@ -191,12 +194,7 @@ "subject": "Subject", "message": "Message", "encryptedBadge": "End-to-end 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" - } + "cleartextBadge": "Not encrypted" }, "preferences": { "title": "Preferences", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 2881dcd..d068e35 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -152,7 +152,10 @@ "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" } }, "modals": { @@ -193,12 +196,7 @@ "subject": "Asunto", "message": "Mensaje", "encryptedBadge": "Cifrado de extremo a extremo", - "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" - } + "cleartextBadge": "Sin cifrar" }, "preferences": { "title": "Preferencias", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 65a10ef..88947d8 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -152,7 +152,10 @@ "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" } }, "modals": { @@ -193,12 +196,7 @@ "subject": "Objet", "message": "Message", "encryptedBadge": "Chiffré de bout en bout", - "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" - } + "cleartextBadge": "Non chiffré" }, "preferences": { "title": "Préférences", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 0d22502..d53293e 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -152,7 +152,10 @@ "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" } }, "modals": { @@ -193,12 +196,7 @@ "subject": "Oggetto", "message": "Messaggio", "encryptedBadge": "Crittografia end-to-end", - "cleartextBadge": "Non crittografato", - "errors": { - "noRecipients": "Aggiungi almeno un destinatario prima di inviare", - "sendFailed": "Impossibile inviare l'email", - "keyLookupFailed": "Impossibile recuperare le chiavi del destinatario" - } + "cleartextBadge": "Non crittografato" }, "preferences": { "title": "Preferenze", diff --git a/src/services/mail-encryption/index.test.ts b/src/services/mail-encryption/index.test.ts index dd12bf9..5a2f0cb 100644 --- a/src/services/mail-encryption/index.test.ts +++ b/src/services/mail-encryption/index.test.ts @@ -1,10 +1,14 @@ -import { describe, expect, test } from 'vitest'; +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(); diff --git a/src/services/mail-encryption/index.ts b/src/services/mail-encryption/index.ts index 750201e..f49e405 100644 --- a/src/services/mail-encryption/index.ts +++ b/src/services/mail-encryption/index.ts @@ -7,6 +7,7 @@ import { 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 }; @@ -51,7 +52,7 @@ export class MailEncryptionService { */ async buildEncryptionBlock(content: EmailContent, recipients: RecipientPublicKey[]): Promise { if (recipients.length === 0) { - throw new Error('At least one recipient is required to build the encryption block'); + throw new BuildEncryptionBlockError(); } const { encEmail, encryptionKey } = await encryptEmail({ text: content.body }); @@ -115,7 +116,7 @@ export class MailEncryptionService { // No op, try the next one. } } - throw new Error('Cannot decrypt envelope: not a recipient or wrong key'); + throw new EnvelopeDecryptionError(); } decryptEnvelope(envelope: EncryptionBlock, keypair: HybridKeyPair): Promise { diff --git a/src/services/recipient-keys/index.test.ts b/src/services/recipient-keys/index.test.ts index 29e8c2c..aa4bb33 100644 --- a/src/services/recipient-keys/index.test.ts +++ b/src/services/recipient-keys/index.test.ts @@ -3,6 +3,7 @@ import { RecipientKeysService } from '.'; describe('RecipientKeysService', () => { beforeEach(() => { + vi.restoreAllMocks(); RecipientKeysService.instance.clear(); vi.useFakeTimers(); vi.setSystemTime(new Date('2026-05-18T00:00:00Z')); @@ -28,6 +29,22 @@ describe('RecipientKeysService', () => { 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'); diff --git a/src/services/recipient-keys/index.ts b/src/services/recipient-keys/index.ts index 0ca4778..726c1d9 100644 --- a/src/services/recipient-keys/index.ts +++ b/src/services/recipient-keys/index.ts @@ -1,6 +1,6 @@ const DEFAULT_TTL_MS = 5 * 60 * 1000; -type CachedKey = { publicKey: string; fetchedAt: number }; +type CachedKey = { publicKey: string | null; fetchedAt: number }; export class RecipientKeysService { public static readonly instance: RecipientKeysService = new RecipientKeysService(); @@ -13,7 +13,7 @@ export class RecipientKeysService { return address.trim().toLowerCase(); } - set(address: string, publicKey: string): void { + set(address: string, publicKey: string | null): void { this.cache.set(this.normalize(address), { publicKey, fetchedAt: Date.now() }); } @@ -24,7 +24,7 @@ export class RecipientKeysService { this.cache.delete(this.normalize(address)); return null; } - return entry; + return { ...entry }; } has(address: string, ttlMs: number = DEFAULT_TTL_MS): boolean { diff --git a/src/store/api/mail/index.ts b/src/store/api/mail/index.ts index 8a5dd00..c5d8b4b 100644 --- a/src/store/api/mail/index.ts +++ b/src/store/api/mail/index.ts @@ -268,7 +268,7 @@ export const mailApi = api.injectEndpoints({ try { const res: LookupRecipientKeysResponse = await MailService.instance.lookupRecipientKeys(normalized); for (const r of res.recipients) { - if (r.publicKey) RecipientKeysService.instance.set(r.address, r.publicKey); + RecipientKeysService.instance.set(r.address, r.publicKey); } return { data: res.recipients }; } catch (error) { diff --git a/src/store/api/mail/mail.api.test.ts b/src/store/api/mail/mail.api.test.ts index f589ff7..cd9a7bb 100644 --- a/src/store/api/mail/mail.api.test.ts +++ b/src/store/api/mail/mail.api.test.ts @@ -591,7 +591,7 @@ describe('Mail API', () => { }); describe('Lookup recipient keys', () => { - test('When looking up keys, then it returns the recipient list and writes through to the cache', async () => { + 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' }, @@ -606,7 +606,8 @@ describe('Mail API', () => { expect(result.data).toStrictEqual(recipients); expect(RecipientKeysService.instance.get('alice@inxt.me')?.publicKey).toBe('pk-alice'); - expect(RecipientKeysService.instance.get('bob@gmail.com')).toBeNull(); + 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 () => { From b6ddff7cb141d2d10adc24edc59fa2a8fa73c0e3 Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Thu, 28 May 2026 14:46:00 -0600 Subject: [PATCH 12/12] test: add unit tests for useComposeSend hook to validate email sending logic and error handling --- .../hooks/useComposeSend.test.ts | 204 ++++++++++++++++++ .../compose-message/hooks/useComposeSend.ts | 25 ++- src/i18n/locales/en.json | 3 +- src/i18n/locales/es.json | 3 +- src/i18n/locales/fr.json | 3 +- src/i18n/locales/it.json | 3 +- src/services/mail-encryption/index.ts | 10 + src/services/recipient-keys/index.ts | 6 +- 8 files changed, 248 insertions(+), 9 deletions(-) create mode 100644 src/components/compose-message/hooks/useComposeSend.test.ts 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 index ef40281..c80d055 100644 --- a/src/components/compose-message/hooks/useComposeSend.ts +++ b/src/components/compose-message/hooks/useComposeSend.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo } from 'react'; import type { Editor } from '@tiptap/react'; -import type { EmailAddress, SendEmailRequest } from '@internxt/sdk/dist/mail/types'; +import type { EmailAddress, RecipientKey, SendEmailRequest } from '@internxt/sdk/dist/mail/types'; import { useGetActiveDomainsQuery, useGetMailAccountKeysQuery, @@ -13,7 +13,7 @@ import notificationsService, { ToastType } from '@/services/notifications'; import { useTranslationContext } from '@/i18n'; import type { Recipient } from '../types'; -export type EncryptionState = 'none' | 'encrypted' | 'cleartext'; +export type EncryptionState = 'none' | 'unknown' | 'encrypted' | 'cleartext'; const toEmailAddress = (r: Recipient): EmailAddress => (r.name ? { name: r.name, email: r.email } : { email: r.email }); @@ -59,7 +59,7 @@ export const useComposeSend = ({ const encryptionState = useMemo(() => { if (allRecipients.length === 0) return 'none'; - if (!activeDomains) return 'none'; + if (!activeDomains) return 'unknown'; return classifyRecipients( allRecipients.map((r) => r.email), activeDomains, @@ -77,6 +77,14 @@ export const useComposeSend = ({ 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 = { @@ -98,7 +106,16 @@ export const useComposeSend = ({ return; } const uniqueAddresses = uniqueEmailAddresses(allRecipients.map((r) => r.email)); - const lookup = await triggerLookup({ addresses: uniqueAddresses }).unwrap(); + 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) { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 39d1942..cbc2b19 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -153,7 +153,8 @@ "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" + "keyLookupFailed": "Could not fetch recipient keys", + "encryptionUnavailable": "Encryption isn't ready yet. Please try again in a moment." } }, "modals": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index d068e35..6fcbcc8 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -155,7 +155,8 @@ "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" + "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": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 88947d8..108f4db 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -155,7 +155,8 @@ "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" + "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": { diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index d53293e..8f4df58 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -155,7 +155,8 @@ "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" + "keyLookupFailed": "Impossibile recuperare le chiavi del destinatario", + "encryptionUnavailable": "La crittografia non è ancora pronta. Riprova tra un momento." } }, "modals": { diff --git a/src/services/mail-encryption/index.ts b/src/services/mail-encryption/index.ts index f49e405..5edf414 100644 --- a/src/services/mail-encryption/index.ts +++ b/src/services/mail-encryption/index.ts @@ -119,10 +119,20 @@ export class MailEncryptionService { 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.ts b/src/services/recipient-keys/index.ts index 726c1d9..6a9c5f9 100644 --- a/src/services/recipient-keys/index.ts +++ b/src/services/recipient-keys/index.ts @@ -7,7 +7,7 @@ export class RecipientKeysService { private constructor() {} - private cache = new Map(); + private readonly cache = new Map(); private normalize(address: string): string { return address.trim().toLowerCase(); @@ -17,6 +17,10 @@ export class RecipientKeysService { 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;