Merge branch 'INDA-330-pii-update' into 'main'
[ProtonMail-WebClient.git] / applications / mail / src / app / hooks / useSendInfo.tsx
blob15f9683d14c2c0eef4912f678ed8384180dce7b3
1 import type { Dispatch, SetStateAction } from 'react';
2 import { useCallback, useEffect, useMemo, useState } from 'react';
4 import { c, msgid } from 'ttag';
6 import { useGetEncryptionPreferences, useKeyTransparencyContext } from '@proton/components';
7 import { useModalTwo } from '@proton/components/components/modalTwo/useModalTwo';
8 import type { PublicKeyReference } from '@proton/crypto';
9 import useIsMounted from '@proton/hooks/useIsMounted';
10 import { processApiRequestsSafe } from '@proton/shared/lib/api/helpers/safeApiRequests';
11 import { validateEmailAddress } from '@proton/shared/lib/helpers/email';
12 import { omit } from '@proton/shared/lib/helpers/object';
13 import type { KeyTransparencyActivation } from '@proton/shared/lib/interfaces';
14 import type { Recipient } from '@proton/shared/lib/interfaces/Address';
15 import type { ContactEmail } from '@proton/shared/lib/interfaces/contacts';
16 import type { GetEncryptionPreferences } from '@proton/shared/lib/interfaces/hooks/GetEncryptionPreferences';
17 import { ENCRYPTION_PREFERENCES_ERROR_TYPES } from '@proton/shared/lib/mail/encryptionPreferences';
18 import { getRecipientsAddresses } from '@proton/shared/lib/mail/messages';
19 import getSendPreferences from '@proton/shared/lib/mail/send/getSendPreferences';
20 import isTruthy from '@proton/utils/isTruthy';
21 import noop from '@proton/utils/noop';
23 import AskForKeyPinningModal from '../components/composer/addresses/AskForKeyPinningModal';
24 import ContactResignModal from '../components/message/modals/ContactResignModal';
25 import { getSendStatusIcon } from '../helpers/message/icon';
26 import type { MapSendInfo } from '../models/crypto';
27 import { STATUS_ICONS_FILLS } from '../models/crypto';
28 import type { ContactsMap } from '../store/contacts/contactsTypes';
29 import type { MessageState } from '../store/messages/messagesTypes';
30 import { useContactsMap } from './contact/useContacts';
32 const { PRIMARY_NOT_PINNED, CONTACT_SIGNATURE_NOT_VERIFIED } = ENCRYPTION_PREFERENCES_ERROR_TYPES;
34 const getSignText = (n: number, contactNames: string, contactAddresses: string) => {
35     return c('Info').ngettext(
36         msgid`The verification of ${contactNames} has failed: the contact is not signed correctly.
37                                     This may be the result of a password reset.
38                                     You must re-sign the contact in order to send a message to ${contactAddresses} or edit the contact.`,
39         `The verification of ${contactNames} has failed: the contacts are not signed correctly.
40                                     This may be the result of a password reset.
41                                     You must re-sign the contacts in order to send a message to ${contactAddresses} or edit the contacts.`,
42         n
43     );
46 export interface MessageSendInfo {
47     message: MessageState;
48     mapSendInfo: MapSendInfo;
49     setMapSendInfo: Dispatch<SetStateAction<MapSendInfo>>;
52 export const useMessageSendInfo = (message: MessageState) => {
53     const isMounted = useIsMounted();
54     // Map of send preferences and send icons for each recipient
55     const [mapSendInfo, setMapSendInfo] = useState<MapSendInfo>({});
56     const safeSetMapSendInfo = (value: SetStateAction<MapSendInfo>) => isMounted() && setMapSendInfo(value);
58     // Use memo is ok there but not really effective as any message change will update the ref
59     const messageSendInfo: MessageSendInfo = useMemo(
60         () => ({
61             message,
62             mapSendInfo,
63             setMapSendInfo: safeSetMapSendInfo,
64         }),
65         [message, mapSendInfo]
66     );
68     return messageSendInfo;
71 export const useUpdateRecipientSendInfo = (
72     messageSendInfo: MessageSendInfo | undefined,
73     recipient: Recipient,
74     onRemove: () => void
75 ) => {
76     const getEncryptionPreferences = useGetEncryptionPreferences();
77     const contactsMap = useContactsMap();
78     const emailAddress = recipient.Address;
79     const { ktActivation } = useKeyTransparencyContext();
81     const [askForKeyPinningModal, handleShowAskForKeyPinningModal] = useModalTwo(AskForKeyPinningModal);
83     const [contactResignModal, handleContactResignModal] = useModalTwo(ContactResignModal);
85     const handleRemove = () => {
86         if (messageSendInfo) {
87             const { setMapSendInfo } = messageSendInfo;
88             setMapSendInfo((mapSendInfo) => omit(mapSendInfo, [emailAddress]));
89         }
90         onRemove();
91     };
93     useEffect(() => {
94         const updateRecipientIcon = async (): Promise<void> => {
95             // Inactive if no send info or data already present
96             if (!messageSendInfo || messageSendInfo.mapSendInfo[emailAddress]) {
97                 return;
98             }
99             const { message, setMapSendInfo } = messageSendInfo;
100             const emailValidation = validateEmailAddress(emailAddress);
102             // Prevent sending request if email is not even valid
103             if (!emailValidation) {
104                 setMapSendInfo((mapSendInfo) => ({
105                     ...mapSendInfo,
106                     [emailAddress]: {
107                         encryptionPreferenceError: ENCRYPTION_PREFERENCES_ERROR_TYPES.EMAIL_ADDRESS_ERROR,
108                         sendPreferences: undefined,
109                         sendIcon: {
110                             colorClassName: 'color-danger',
111                             isEncrypted: false,
112                             fill: STATUS_ICONS_FILLS.FAIL,
113                             text: c('Composer email icon').t`The address might be misspelled`,
114                         },
115                         loading: false,
116                         emailValidation,
117                         emailAddressWarnings: [],
118                         contactSignatureInfo: undefined,
119                     },
120                 }));
121                 return;
122             }
123             setMapSendInfo((mapSendInfo) => ({
124                 ...mapSendInfo,
125                 [emailAddress]: {
126                     loading: true,
127                     emailValidation,
128                 },
129             }));
130             const encryptionPreferences = await getEncryptionPreferences({
131                 email: emailAddress,
132                 lifetime: 0,
133                 contactEmailsMap: contactsMap,
134             });
135             const sendPreferences = getSendPreferences(encryptionPreferences, message.data);
137             if (sendPreferences.error?.type === CONTACT_SIGNATURE_NOT_VERIFIED) {
138                 if (!recipient.ContactID) {
139                     return;
140                 }
141                 const contact = { contactID: recipient.ContactID };
143                 const contactAddress = recipient.Address;
144                 const contactName = recipient.Name || contactAddress;
146                 const text = getSignText(1, contactName, contactAddress);
148                 await handleContactResignModal({
149                     title: c('Title').t`Re-sign contact`,
150                     contacts: [contact],
151                     onNotResign: onRemove,
152                     onError: handleRemove,
153                     children: text,
154                 });
156                 return updateRecipientIcon();
157             }
159             if (sendPreferences.error?.type === PRIMARY_NOT_PINNED) {
160                 if (!recipient.ContactID) {
161                     return;
162                 }
163                 const contacts = [
164                     {
165                         contactID: recipient.ContactID,
166                         emailAddress,
167                         isInternal: encryptionPreferences.isInternal,
168                         bePinnedPublicKey: encryptionPreferences.sendKey as PublicKeyReference,
169                     },
170                 ];
172                 await handleShowAskForKeyPinningModal({
173                     contacts,
174                     onNotTrust: handleRemove,
175                     onError: handleRemove,
176                 });
178                 return updateRecipientIcon();
179             }
180             const sendIcon = getSendStatusIcon(sendPreferences);
181             const contactSignatureInfo = {
182                 isVerified: encryptionPreferences.isContactSignatureVerified,
183                 creationTime: encryptionPreferences.contactSignatureTimestamp,
184             };
186             setMapSendInfo((mapSendInfo) => ({
187                 ...mapSendInfo,
188                 [emailAddress]: {
189                     sendPreferences,
190                     sendIcon,
191                     loading: false,
192                     emailValidation,
193                     encryptionPreferenceError: encryptionPreferences.error?.type,
194                     emailAddressWarnings: encryptionPreferences.emailAddressWarnings || [],
195                     contactSignatureInfo,
196                 },
197             }));
198         };
200         void updateRecipientIcon();
201     }, [emailAddress, contactsMap, ktActivation]);
203     return { handleRemove, askForKeyPinningModal, contactResignModal };
206 interface LoadParams {
207     emailAddress: string;
208     contactID: string;
209     contactName: string;
210     abortController: AbortController;
211     checkForError: boolean;
214 export const useUpdateGroupSendInfo = (
215     messageSendInfo: MessageSendInfo | undefined,
216     contacts: ContactEmail[],
217     onRemove: () => void
218 ) => {
219     const getEncryptionPreferences = useGetEncryptionPreferences();
220     const contactsMap = useContactsMap();
221     const emailsInGroup = contacts.map(({ Email }) => Email);
223     const [askForKeyPinningModal, handleShowAskForKeyPinningModal] = useModalTwo(AskForKeyPinningModal);
225     const [contactResignModal, handleContactResignModal] = useModalTwo(ContactResignModal);
227     const handleRemove = () => {
228         if (messageSendInfo) {
229             const { setMapSendInfo } = messageSendInfo;
230             setMapSendInfo((mapSendInfo) => omit(mapSendInfo, emailsInGroup));
231         }
232         onRemove();
233     };
235     const { ktActivation } = useKeyTransparencyContext();
237     useEffect(() => {
238         const abortController = new AbortController();
239         // loadSendIcon tries to load the corresponding icon for an email address. If all goes well, it returns nothing.
240         // If there are errors, it returns an error type and information about the email address that failed
241         const loadSendIcon = async ({
242             emailAddress,
243             contactID,
244             contactName,
245             abortController,
246             checkForError,
247         }: LoadParams) => {
248             const { signal } = abortController;
249             const icon = messageSendInfo?.mapSendInfo[emailAddress]?.sendIcon;
250             const emailValidation = validateEmailAddress(emailAddress);
251             if (
252                 !emailValidation ||
253                 !emailAddress ||
254                 icon ||
255                 !messageSendInfo ||
256                 !!messageSendInfo.mapSendInfo[emailAddress] ||
257                 signal.aborted
258             ) {
259                 return;
260             }
262             const { message, setMapSendInfo } = messageSendInfo;
263             if (!signal.aborted) {
264                 setMapSendInfo((mapSendInfo) => {
265                     const sendInfo = mapSendInfo[emailAddress];
266                     return {
267                         ...mapSendInfo,
268                         [emailAddress]: { ...sendInfo, loading: true, emailValidation },
269                     };
270                 });
271             }
272             const encryptionPreferences = await getEncryptionPreferences({
273                 email: emailAddress,
274                 lifetime: 0,
275                 contactEmailsMap: contactsMap,
276             });
277             const sendPreferences = getSendPreferences(encryptionPreferences, message.data);
278             const sendIcon = getSendStatusIcon(sendPreferences);
279             const contactSignatureInfo = {
280                 isVerified: encryptionPreferences.isContactSignatureVerified,
281                 creationTime: encryptionPreferences.contactSignatureTimestamp,
282             };
283             if (!signal.aborted) {
284                 setMapSendInfo((mapSendInfo) => ({
285                     ...mapSendInfo,
286                     [emailAddress]: {
287                         sendPreferences,
288                         sendIcon,
289                         loading: false,
290                         emailValidation,
291                         emailAddressWarnings: encryptionPreferences.emailAddressWarnings || [],
292                         contactSignatureInfo,
293                     },
294                 }));
295             }
296             if (checkForError && sendPreferences.error) {
297                 return {
298                     error: sendPreferences.error,
299                     contact: {
300                         contactID,
301                         contactName,
302                         emailAddress,
303                         isInternal: encryptionPreferences.isInternal,
304                         bePinnedPublicKey: encryptionPreferences.sendKey as PublicKeyReference,
305                     },
306                 };
307             }
308         };
310         const loadSendIcons = async ({
311             abortController,
312             checkForError,
313         }: Pick<LoadParams, 'abortController' | 'checkForError'>): Promise<void> => {
314             const requests = contacts.map(
315                 ({ Email, ContactID, Name }) =>
316                     () =>
317                         loadSendIcon({
318                             emailAddress: Email,
319                             contactID: ContactID,
320                             contactName: Name,
321                             abortController,
322                             checkForError,
323                         })
324             );
325             // the routes called in requests support 100 calls every 10 seconds
326             const results = await processApiRequestsSafe(requests, 100, 10 * 1000);
327             const contactsResign = results
328                 .filter(isTruthy)
329                 .filter(({ error: { type } }) => type === CONTACT_SIGNATURE_NOT_VERIFIED)
330                 .map(({ contact }) => contact);
331             const totalContactsResign = contactsResign.length;
332             if (totalContactsResign) {
333                 const title = c('Title').ngettext(msgid`Re-sign contact`, `Re-sign contacts`, totalContactsResign);
334                 const contactNames = contactsResign.map(({ contactName }) => contactName).join(', ');
335                 const contactAddresses = contactsResign.map(({ emailAddress }) => emailAddress).join(', ');
337                 const text = getSignText(totalContactsResign, contactNames, contactAddresses);
339                 await handleContactResignModal({
340                     title: title,
341                     contacts: contactsResign,
342                     onNotResign: noop,
343                     onError: noop,
344                     children: text,
345                 });
347                 return loadSendIcons({ abortController, checkForError: false });
348             }
349             const contactsKeyPinning = results
350                 .filter(isTruthy)
351                 .filter(({ error: { type } }) => type === PRIMARY_NOT_PINNED)
352                 .map(({ contact }) => contact);
353             if (contactsKeyPinning.length) {
354                 await handleShowAskForKeyPinningModal({
355                     contacts: contactsKeyPinning,
356                     onNotTrust: noop,
357                     onError: noop,
358                 });
360                 return loadSendIcons({ abortController, checkForError: false });
361             }
362         };
364         void loadSendIcons({ abortController, checkForError: true });
366         return () => {
367             abortController.abort();
368         };
369     }, [ktActivation]);
371     return { handleRemove, askForKeyPinningModal, contactResignModal };
374 const getUpdatedSendInfo = async (
375     emailAddress: string,
376     message: MessageState,
377     setMapSendInfo: Dispatch<SetStateAction<MapSendInfo>>,
378     getEncryptionPreferences: GetEncryptionPreferences,
379     ktActivation: KeyTransparencyActivation,
380     contactsMap: ContactsMap
381 ) => {
382     const encryptionPreferences = await getEncryptionPreferences({
383         email: emailAddress,
384         lifetime: 0,
385         contactEmailsMap: contactsMap,
386     });
387     const sendPreferences = getSendPreferences(encryptionPreferences, message.data);
388     const sendIcon = getSendStatusIcon(sendPreferences);
389     const contactSignatureInfo = {
390         isVerified: encryptionPreferences.isContactSignatureVerified,
391         creationTime: encryptionPreferences.contactSignatureTimestamp,
392     };
393     const updatedSendInfo = {
394         sendPreferences,
395         sendIcon,
396         loading: false,
397         emailAddressWarnings: encryptionPreferences.emailAddressWarnings || [],
398         contactSignatureInfo,
399     };
400     setMapSendInfo((mapSendInfo) => {
401         const sendInfo = mapSendInfo[emailAddress];
402         if (!sendInfo) {
403             return { ...mapSendInfo };
404         }
405         return {
406             ...mapSendInfo,
407             [emailAddress]: { ...sendInfo, ...updatedSendInfo },
408         };
409     });
412 export const useReloadSendInfo = () => {
413     const getEncryptionPreferences = useGetEncryptionPreferences();
414     const contactsMap = useContactsMap();
415     const { ktActivation } = useKeyTransparencyContext();
417     return useCallback(
418         async (messageSendInfo: MessageSendInfo | undefined, message: MessageState) => {
419             const { mapSendInfo, setMapSendInfo } = messageSendInfo || {};
421             if (mapSendInfo === undefined || !setMapSendInfo || !message.data) {
422                 return;
423             }
425             const recipients = getRecipientsAddresses(message.data);
426             const requests = recipients.map(
427                 (emailAddress) => () =>
428                     getUpdatedSendInfo(
429                         emailAddress,
430                         message,
431                         setMapSendInfo,
432                         getEncryptionPreferences,
433                         ktActivation,
434                         contactsMap
435                     )
436             );
437             const loadingMapSendInfo = recipients.reduce(
438                 (acc, emailAddress) => {
439                     const sendInfo = acc[emailAddress] || { emailValidation: validateEmailAddress(emailAddress) };
440                     acc[emailAddress] = { ...sendInfo, loading: true };
441                     return acc;
442                 },
443                 { ...mapSendInfo }
444             );
445             setMapSendInfo(loadingMapSendInfo);
446             // the routes called in requests support 100 calls every 10 seconds
447             await processApiRequestsSafe(requests, 100, 10 * 1000);
448         },
449         [getEncryptionPreferences, contactsMap, ktActivation]
450     );