Skip to content

Commit 57aa4ef

Browse files
committed
refactor: update encryption handling and address management in email components
- Enhanced the encryption logic to include recipient addresses for better key management. - Updated the versioning of encryption blocks from v1 to v2 across various components and tests. - Implemented checks to prevent sending encrypted emails with Bcc recipients, ensuring compliance with encryption protocols. - Adjusted related tests to reflect changes in encryption handling and recipient address management.
1 parent d530e5e commit 57aa4ef

15 files changed

Lines changed: 221 additions & 166 deletions

src/components/compose-message/hooks/useComposeSend.test.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,11 @@ const recipient = (email: string): Recipient => ({ id: email, email });
3535
const show = vi.mocked(notificationsService.show);
3636

3737
const mockEncryptionBlock = {
38-
version: 'v1',
38+
version: 'v2',
3939
encryptedText: 'ct',
40-
encryptedPreview: 'cp',
4140
wrappedKeys: [],
42-
attachmentWrappedKeys: [],
41+
encryptedPreview: 'cp',
42+
previewWrappedKeys: [],
4343
};
4444

4545
const renderSend = (overrides: Partial<Parameters<typeof useComposeSend>[0]> = {}) => {
@@ -175,11 +175,31 @@ describe('useComposeSend', () => {
175175
expect.any(Uint8Array),
176176
);
177177
expect(mocks.sendEmail).toHaveBeenCalledWith(
178-
expect.objectContaining({ encryption: expect.objectContaining({ version: 'v1' }), deliveryMode: 'INTERNXT' }),
178+
expect.objectContaining({ encryption: expect.objectContaining({ version: 'v2' }), deliveryMode: 'INTERNXT' }),
179179
);
180180
expect(onSent).toHaveBeenCalled();
181181
});
182182

183+
test('When all recipients are Internxt and one is Bcc, then the send is blocked', async () => {
184+
const buildSpy = vi.spyOn(MailEncryptionService.instance, 'buildEncryptionBlock');
185+
186+
const { result, onSent } = renderSend({
187+
toRecipients: [recipient('bob@inxt.me')],
188+
bccRecipients: [recipient('carol@inxt.me')],
189+
});
190+
191+
expect(result.current.encryptionState).toBe('internxt');
192+
193+
await act(async () => {
194+
await result.current.send();
195+
});
196+
197+
expect(show).toHaveBeenCalledWith(expect.objectContaining({ text: 'errors.mail.bccNotSupportedEncrypted' }));
198+
expect(buildSpy).not.toHaveBeenCalled();
199+
expect(mocks.sendEmail).not.toHaveBeenCalled();
200+
expect(onSent).not.toHaveBeenCalled();
201+
});
202+
183203
describe('external recipients', () => {
184204
let getVariable: ReturnType<typeof vi.spyOn>;
185205

@@ -226,7 +246,7 @@ describe('useComposeSend', () => {
226246
expect.any(Uint8Array),
227247
);
228248
expect(mocks.sendEmail).toHaveBeenCalledWith(
229-
expect.objectContaining({ deliveryMode: 'EXTERNAL', encryption: expect.objectContaining({ version: 'v1' }) }),
249+
expect.objectContaining({ deliveryMode: 'EXTERNAL', encryption: expect.objectContaining({ version: 'v2' }) }),
230250
);
231251
expect(onSent).toHaveBeenCalled();
232252
});

src/components/compose-message/hooks/useComposeSend.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@ export const useComposeSend = ({
9696

9797
if (pendingInherited.length > 0) {
9898
const senderKeysForAttachments = MailKeysService.instance.getCurrentKeys();
99-
if (!senderKeysForAttachments) {
99+
const senderAddress = MailKeysService.instance.getCurrentAddress();
100+
if (!senderKeysForAttachments || !senderAddress) {
100101
notificationsService.show({
101102
text: translate('errors.mail.forwardAttachmentFailed'),
102103
type: ToastType.Error,
@@ -110,6 +111,7 @@ export const useComposeSend = ({
110111
const originalSessionKey = await MailEncryptionService.instance.decryptAttachmentsSessionKey(
111112
item.originalEnvelope,
112113
senderKeysForAttachments,
114+
senderAddress,
113115
);
114116

115117
const { blob } = await NetworkService.instance.download({
@@ -167,6 +169,12 @@ export const useComposeSend = ({
167169
return;
168170
}
169171

172+
// TODO: remove this once per-recipient delivery is implemented
173+
if (encryptionState === 'internxt' && bccRecipients.length > 0) {
174+
notificationsService.show({ text: translate('errors.mail.bccNotSupportedEncrypted'), type: ToastType.Warning });
175+
return;
176+
}
177+
170178
if (!senderKeys?.address || !senderKeys.publicKey) {
171179
notificationsService.show({ text: translate('errors.mail.keyLookupFailed'), type: ToastType.Error });
172180
return;

src/components/compose-message/hooks/useDraftMessage.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@ const editor = { getHTML: () => '<p>hi</p>', getText: () => 'hi', on: vi.fn(), o
2525
const recipient = (email: string): Recipient => ({ id: email, email });
2626

2727
const mockEncryptionBlock = {
28-
version: 'v1',
28+
version: 'v2',
2929
encryptedText: 'ct',
30-
encryptedPreview: 'cp',
3130
wrappedKeys: [],
32-
attachmentWrappedKeys: [],
31+
encryptedPreview: 'cp',
32+
previewWrappedKeys: [],
3333
};
3434

3535
const renderDraft = (overrides: Partial<Parameters<typeof useDraftMessage>[0]> = {}) => {
@@ -84,7 +84,7 @@ describe('Draft Message', () => {
8484
expect.objectContaining({
8585
subject: 'A subject',
8686
to: [{ email: 'bob@inxt.me' }],
87-
encryption: expect.objectContaining({ version: 'v1' }),
87+
encryption: expect.objectContaining({ version: 'v2' }),
8888
}),
8989
);
9090
});

src/components/compose-message/index.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,10 @@ export const ComposeMessageDialog = () => {
128128
const envelope = composeDialogData?.mode === 'draft' ? composeDialogData.draft.encryption : undefined;
129129
if (!envelope) return;
130130
const keys = MailKeysService.instance.getCurrentKeys();
131-
if (!keys) return;
131+
const address = MailKeysService.instance.getCurrentAddress();
132+
if (!keys || !address) return;
132133
MailEncryptionService.instance
133-
.decryptAttachmentsSessionKey(envelope as EncryptionBlock, keys)
134+
.decryptAttachmentsSessionKey(envelope as EncryptionBlock, keys, address)
134135
.then((key) => {
135136
setAttachmentsSessionKey(key);
136137
sessionKeyHydratedRef.current = true;

src/hooks/mail/useAttachmentsSessionKey.test.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,17 @@ import { MailKeysService } from '@/services/mail-keys';
99
const KEY = new Uint8Array([1, 2, 3, 4]);
1010
const KEYPAIR = { secretKey: new Uint8Array(32), publicKey: new Uint8Array(32) } as unknown as HybridKeyPair;
1111
const ENVELOPE = {
12-
version: 'v1',
12+
version: 'v2',
1313
encryptedText: 'ct',
14-
encryptedPreview: 'cp',
1514
wrappedKeys: [],
16-
attachmentWrappedKeys: [],
15+
encryptedPreview: 'cp',
16+
previewWrappedKeys: [],
1717
} as unknown as EncryptionBlock;
1818

1919
const setKeys = (keys: HybridKeyPair | null) => {
2020
const spy = vi.spyOn(MailKeysService.instance, 'getCurrentKeys');
2121
spy.mockReturnValue(keys);
22+
vi.spyOn(MailKeysService.instance, 'getCurrentAddress').mockReturnValue(keys ? 'me@inxt.me' : null);
2223
return spy;
2324
};
2425

src/hooks/mail/useAttachmentsSessionKey.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,16 @@ export const useAttachmentsSessionKey = (
1616
envelope: EncryptionBlock | null,
1717
): Uint8Array | null => {
1818
const keypair = MailKeysService.instance.getCurrentKeys();
19+
const address = MailKeysService.instance.getCurrentAddress();
1920
const [cache, setCache] = useState<Record<string, CachedKey>>({});
2021

2122
useEffect(() => {
22-
if (!mailId || !envelope || !keypair) return;
23+
if (!mailId || !envelope || !keypair || !address) return;
2324
if (cache[mailId]) return;
2425

2526
let cancelled = false;
2627
MailEncryptionService.instance
27-
.decryptAttachmentsSessionKey(envelope, keypair)
28+
.decryptAttachmentsSessionKey(envelope, keypair, address)
2829
.then((key) => {
2930
if (!cancelled) setCache((prev) => ({ ...prev, [mailId]: { ok: true, key } }));
3031
})
@@ -36,7 +37,7 @@ export const useAttachmentsSessionKey = (
3637
return () => {
3738
cancelled = true;
3839
};
39-
}, [mailId, envelope, keypair, cache]);
40+
}, [mailId, envelope, keypair, address, cache]);
4041

4142
if (!mailId) return null;
4243
const entry = cache[mailId];

src/hooks/mail/useDecryptedPreviews.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const plainSummary = (id: string): Summary => ({ id }) as unknown as Summary;
1515

1616
const setKeys = (keys: HybridKeyPair | null) => {
1717
vi.spyOn(MailKeysService.instance, 'getCurrentKeys').mockReturnValue(keys);
18+
vi.spyOn(MailKeysService.instance, 'getCurrentAddress').mockReturnValue(keys ? 'me@inxt.me' : null);
1819
};
1920

2021
const spyOnPreviewDecrypt = () => vi.spyOn(MailEncryptionService.instance, 'decryptSummaryPreview');

src/hooks/mail/useDecryptedPreviews.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,19 @@ import { MailKeysService } from '@/services/mail-keys';
66

77
type Summary = EmailListResponse['emails'][number];
88

9-
const decryptPendingPreviews = async (pending: Summary[], keypair: HybridKeyPair): Promise<Record<string, string>> => {
9+
const decryptPendingPreviews = async (
10+
pending: Summary[],
11+
keypair: HybridKeyPair,
12+
address: string,
13+
): Promise<Record<string, string>> => {
1014
const resolved: Record<string, string> = {};
1115
for (const summary of pending) {
1216
try {
13-
resolved[summary.id] = await MailEncryptionService.instance.decryptSummaryPreview(summary.encryption!, keypair);
17+
resolved[summary.id] = await MailEncryptionService.instance.decryptSummaryPreview(
18+
summary.encryption!,
19+
keypair,
20+
address,
21+
);
1422
} catch (error) {
1523
console.error('Failed to decrypt mail preview', { mailId: summary.id, error });
1624
}
@@ -21,27 +29,28 @@ const decryptPendingPreviews = async (pending: Summary[], keypair: HybridKeyPair
2129
/**
2230
* Decrypts the preview snippet for the encrypted rows on a list page. The
2331
* backend projects an `encryption` block ({ encryptedPreview, wrappedKeys })
24-
* onto each encrypted summary the caller can read; we trial-decrypt it with the
25-
* caller's keypair, exactly as the full body is decrypted.
32+
* onto each encrypted summary the caller can read; the caller's wrapped key is
33+
* found by its address label, exactly as the full body is decrypted.
2634
*
2735
* Returns a map of `emailId -> decrypted preview`. Rows are decrypted at most
2836
* once (tracked in `attempted`), so re-renders and pagination don't re-run the
2937
* crypto, and a row that fails simply stays absent
3038
*/
3139
export const useDecryptedPreviews = (summaries: Summary[] | undefined): Record<string, string> => {
3240
const senderKeys = MailKeysService.instance.getCurrentKeys();
41+
const senderAddress = MailKeysService.instance.getCurrentAddress();
3342
const [previews, setPreviews] = useState<Record<string, string>>({});
3443
const attempted = useRef<Set<string>>(new Set());
3544

3645
useEffect(() => {
37-
if (!senderKeys || !summaries?.length) return;
46+
if (!senderKeys || !senderAddress || !summaries?.length) return;
3847

3948
const pending = summaries.filter((s) => s.encryption && !attempted.current.has(s.id));
4049
if (pending.length === 0) return;
4150
pending.forEach((s) => attempted.current.add(s.id));
4251

4352
let cancelled = false;
44-
decryptPendingPreviews(pending, senderKeys).then((resolved) => {
53+
decryptPendingPreviews(pending, senderKeys, senderAddress).then((resolved) => {
4554
if (!cancelled && Object.keys(resolved).length) {
4655
setPreviews((prev) => ({ ...prev, ...resolved }));
4756
}
@@ -50,7 +59,7 @@ export const useDecryptedPreviews = (summaries: Summary[] | undefined): Record<s
5059
return () => {
5160
cancelled = true;
5261
};
53-
}, [summaries, senderKeys]);
62+
}, [summaries, senderKeys, senderAddress]);
5463

5564
return previews;
5665
};

src/i18n/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@
170170
"forwardAttachmentFailed": "Could not prepare an attachment for forwarding. Please try again.",
171171
"internxtKeyMissing": "Could not find encryption keys for an Internxt recipient. Please try again later.",
172172
"encryptionUnavailable": "Encryption isn't ready yet. Please try again in a moment.",
173+
"bccNotSupportedEncrypted": "Bcc isn't available for encrypted emails yet. Move the recipient to To or Cc.",
173174
"draftSaveFailed": "Could not save the draft. Your changes are still here.",
174175
"draftDiscardFailed": "Could not delete the draft. Please try again.",
175176
"draftOpenFailed": "Could not open the draft. Please try again."

src/i18n/locales/es.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@
172172
"forwardAttachmentFailed": "No se pudo preparar un adjunto para reenviar. Inténtalo de nuevo.",
173173
"internxtKeyMissing": "No se encontraron claves de cifrado para un destinatario de Internxt. Inténtalo de nuevo más tarde.",
174174
"encryptionUnavailable": "El cifrado aún no está listo. Inténtalo de nuevo en un momento.",
175+
"bccNotSupportedEncrypted": "Cco aún no está disponible para correos cifrados. Mueve el destinatario a Para o Cc.",
175176
"draftSaveFailed": "No se pudo guardar el borrador. Tus cambios siguen aquí.",
176177
"draftDiscardFailed": "No se pudo eliminar el borrador. Inténtalo de nuevo.",
177178
"draftOpenFailed": "No se pudo abrir el borrador. Inténtalo de nuevo."

0 commit comments

Comments
 (0)