1 import { useEffect, useState } from 'react';
3 import { c } from 'ttag';
5 import { Button } from '@proton/atoms';
6 import Alert from '@proton/components/components/alert/Alert';
7 import Collapsible from '@proton/components/components/collapsible/Collapsible';
8 import CollapsibleContent from '@proton/components/components/collapsible/CollapsibleContent';
9 import CollapsibleHeader from '@proton/components/components/collapsible/CollapsibleHeader';
10 import CollapsibleHeaderIconButton from '@proton/components/components/collapsible/CollapsibleHeaderIconButton';
11 import Field from '@proton/components/components/container/Field';
12 import Row from '@proton/components/components/container/Row';
13 import Icon from '@proton/components/components/icon/Icon';
14 import Label from '@proton/components/components/label/Label';
15 import Info from '@proton/components/components/link/Info';
16 import type { ModalProps } from '@proton/components/components/modalTwo/Modal';
17 import ModalTwo from '@proton/components/components/modalTwo/Modal';
18 import ModalTwoContent from '@proton/components/components/modalTwo/ModalContent';
19 import ModalTwoFooter from '@proton/components/components/modalTwo/ModalFooter';
20 import ModalTwoHeader from '@proton/components/components/modalTwo/ModalHeader';
21 import useApi from '@proton/components/hooks/useApi';
22 import useEventManager from '@proton/components/hooks/useEventManager';
23 import useNotifications from '@proton/components/hooks/useNotifications';
24 import { useLoading } from '@proton/hooks';
25 import { useMailSettings } from '@proton/mail/mailSettings/hooks';
26 import getPublicKeysEmailHelper from '@proton/shared/lib/api/helpers/getPublicKeysEmailHelper';
27 import { extractScheme } from '@proton/shared/lib/api/helpers/mailSettings';
28 import type { CONTACT_MIME_TYPES } from '@proton/shared/lib/constants';
29 import { MIME_TYPES, MIME_TYPES_MORE, PGP_SCHEMES } from '@proton/shared/lib/constants';
30 import { VCARD_KEY_FIELDS } from '@proton/shared/lib/contacts/constants';
31 import { getKeyInfoFromProperties, getMimeTypeVcard, toKeyProperty } from '@proton/shared/lib/contacts/keyProperties';
33 createContactPropertyUid,
36 } from '@proton/shared/lib/contacts/properties';
37 import type { ContactPublicKeyModelWithApiKeySource } from '@proton/shared/lib/interfaces';
38 import type { VCardContact, VCardProperty } from '@proton/shared/lib/interfaces/contacts/VCard';
40 getContactPublicKeyModel,
44 } from '@proton/shared/lib/keys/publicKeys';
45 import clsx from '@proton/utils/clsx';
46 import uniqueBy from '@proton/utils/uniqueBy';
48 import { useKeyTransparencyContext } from '../../keyTransparency/useKeyTransparencyContext';
49 import { useSaveVCardContact } from '../hooks/useSaveVCardContact';
50 import ContactMIMETypeSelect from './ContactMIMETypeSelect';
51 import ContactPGPSettings from './ContactPGPSettings';
53 const { PGP_INLINE } = PGP_SCHEMES;
55 export interface ContactEmailSettingsProps {
57 vCardContact: VCardContact;
58 emailProperty: VCardProperty<string>;
62 type Props = ContactEmailSettingsProps & ModalProps;
64 const ContactEmailSettingsModal = ({ contactID, vCardContact, emailProperty, ...rest }: Props) => {
65 const { value: emailAddressValue, group: emailGroup } = emailProperty;
66 const emailAddress = emailAddressValue as string;
69 const { call } = useEventManager();
70 const [model, setModel] = useState<ContactPublicKeyModelWithApiKeySource>();
71 const [showPgpSettings, setShowPgpSettings] = useState(false);
72 const [loadingPgpSettings, withLoadingPgpSettings] = useLoading(true);
73 const [loadingSave, withLoadingSave] = useLoading(false);
74 const { createNotification } = useNotifications();
75 const [mailSettings] = useMailSettings();
76 const { verifyOutboundPublicKeys, ktActivation } = useKeyTransparencyContext();
78 const saveVCardContact = useSaveVCardContact();
80 // Avoid nested ternary
81 let isMimeTypeFixed: boolean;
82 if (model?.isPGPInternal) {
83 isMimeTypeFixed = false;
85 isMimeTypeFixed = model?.sign !== undefined ? model.sign : !!mailSettings?.Sign;
88 const hasPGPInline = model && mailSettings ? extractScheme(model, mailSettings) === PGP_INLINE : false;
91 * Initialize the key model for the modal
93 const prepare = async () => {
94 const apiKeysConfig = await getPublicKeysEmailHelper({
96 includeInternalKeysWithE2EEDisabledForMail: true, // the keys are used in the context of calendar sharing, thus users may want to pin them
99 verifyOutboundPublicKeys,
102 const apiKeysSourceMap = apiKeysConfig.publicKeys.reduce<
103 ContactPublicKeyModelWithApiKeySource['apiKeysSourceMap']
104 >((map, { publicKey, source }) => {
105 const fingerprint = publicKey.getFingerprint();
107 map[source] = new Set();
109 map[source]!.add(fingerprint);
112 const pinnedKeysConfig = await getKeyInfoFromProperties(vCardContact, emailGroup || '');
113 const publicKeyModel = await getContactPublicKeyModel({
116 pinnedKeysConfig: { ...pinnedKeysConfig, isContact: true },
120 // Encryption enforces signing, so we can ignore the signing preference so that if the user
121 // disables encryption, the global default signing setting is automatically selected.
122 sign: publicKeyModel.encrypt ? undefined : publicKeyModel.sign,
128 * Collect keys from the model to save
129 * @param group attached to the current email address
130 * @returns key properties to save in the vCard
132 const getKeysProperties = (group: string, model: ContactPublicKeyModelWithApiKeySource) => {
133 const allKeys = model?.isPGPInternal
134 ? [...model.publicKeys.apiKeys]
135 : [...model.publicKeys?.apiKeys, ...model.publicKeys.pinnedKeys];
136 const trustedKeys = allKeys.filter((publicKey) => model.trustedFingerprints.has(publicKey.getFingerprint()));
137 const uniqueTrustedKeys = uniqueBy(trustedKeys, (publicKey) => publicKey.getFingerprint());
138 return Promise.all(uniqueTrustedKeys.map((publicKey, index) => toKeyProperty({ publicKey, group, index })));
142 * Save relevant key properties in the vCard
144 const handleSubmit = async (model?: ContactPublicKeyModelWithApiKeySource) => {
148 const properties = getVCardProperties(vCardContact);
149 const newProperties = properties.filter(({ field, group }) => {
150 return !VCARD_KEY_FIELDS.includes(field) || (group && group !== emailGroup);
152 newProperties.push(...(await getKeysProperties(emailGroup || '', model)));
154 const mimeType = getMimeTypeVcard(model.mimeType);
157 field: 'x-pm-mimetype',
160 uid: createContactPropertyUid(),
164 if (model.isPGPExternal) {
165 const hasPinnedKeys = model.publicKeys.pinnedKeys.length > 0;
166 const hasApiKeys = model.publicKeys.apiKeys.length > 0; // from WKD or other untrusted servers
168 if ((hasPinnedKeys || hasApiKeys) && model.encrypt !== undefined) {
170 field: hasPinnedKeys ? 'x-pm-encrypt' : 'x-pm-encrypt-untrusted',
171 value: `${model.encrypt}`,
173 uid: createContactPropertyUid(),
177 // Encryption automatically enables signing (but we do not store the info for non-pinned WKD keys).
178 const sign = model.encrypt || model.sign;
179 if (sign !== undefined) {
184 uid: createContactPropertyUid(),
190 field: 'x-pm-scheme',
193 uid: createContactPropertyUid(),
198 const newVCardContact = fromVCardProperties(newProperties);
201 await saveVCardContact(contactID, newVCardContact);
205 createNotification({ text: c('Success').t`Preferences saved` });
213 * On the first render, initialize the model
216 void withLoadingPgpSettings(prepare());
220 * When the list of trusted, expired or revoked keys change,
222 * * re-check if the new keys can send
223 * * re-order api keys (trusted take preference)
224 * * move expired keys to the bottom of the list
227 setModel((model?: ContactPublicKeyModelWithApiKeySource) => {
234 obsoleteFingerprints,
235 compromisedFingerprints,
236 encryptionCapableFingerprints,
238 const apiKeys = sortApiKeys({
239 keys: publicKeys.apiKeys,
241 obsoleteFingerprints,
242 compromisedFingerprints,
244 const pinnedKeys = sortPinnedKeys({
245 keys: publicKeys.pinnedKeys,
246 obsoleteFingerprints,
247 compromisedFingerprints,
248 encryptionCapableFingerprints,
250 const verifyingPinnedKeys = getVerifyingKeys(pinnedKeys, model.compromisedFingerprints);
254 publicKeys: { apiKeys, pinnedKeys, verifyingPinnedKeys },
258 model?.trustedFingerprints,
259 model?.obsoleteFingerprints,
260 model?.encryptionCapableFingerprints,
261 model?.compromisedFingerprints,
265 // take into account rules relating email format and cryptographic scheme
266 if (!isMimeTypeFixed) {
269 // PGP/Inline should force the email format to plaintext
271 return setModel((model?: ContactPublicKeyModelWithApiKeySource) => {
275 return { ...model, mimeType: MIME_TYPES.PLAINTEXT };
278 // If PGP/Inline is not selected, go back to automatic
279 setModel((model?: ContactPublicKeyModelWithApiKeySource) => {
283 return { ...model, mimeType: MIME_TYPES_MORE.AUTOMATIC };
285 }, [isMimeTypeFixed, hasPGPInline]);
288 <ModalTwo size="large" className="contacts-modal" {...rest}>
290 title={c('Title').t`Edit email settings`}
291 titleClassName="text-ellipsis"
292 subline={emailAddress}
295 {!isMimeTypeFixed ? (
296 <Alert className="mb-4">
298 .t`Select the email format you want to be used by default when sending an email to this email address.`}
301 {isMimeTypeFixed && hasPGPInline ? (
302 <Alert className="mb-4">{c('Info').t`PGP/Inline is only compatible with Plain Text format.`}</Alert>
304 {isMimeTypeFixed && !hasPGPInline ? (
305 <Alert className="mb-4">
306 {c('Info').t`PGP/MIME automatically sends the message using the current composer mode.`}
311 {c('Label').t`Email format`}
315 .t`Automatic indicates that the format in the composer is used to send to this user. Plain text indicates that the message will always be converted to plain text on send.`}
319 <ContactMIMETypeSelect
320 disabled={loadingSave || isMimeTypeFixed}
321 value={model?.mimeType || ''}
322 onChange={(mimeType: CONTACT_MIME_TYPES) =>
323 setModel((model?: ContactPublicKeyModelWithApiKeySource) => {
327 return { ...model, mimeType };
333 <div className="mb-4">
334 <Collapsible disabled={loadingPgpSettings}>
337 <CollapsibleHeaderIconButton onClick={() => setShowPgpSettings(!showPgpSettings)}>
338 <Icon name="chevron-down" />
339 </CollapsibleHeaderIconButton>
342 onClick={() => setShowPgpSettings(!showPgpSettings)}
345 loadingPgpSettings ? 'color-weak text-no-decoration' : 'text-underline',
349 ? c('Action').t`Hide advanced PGP settings`
350 : c('Action').t`Show advanced PGP settings`}
352 <CollapsibleContent className="mt-4">
353 {showPgpSettings && model ? (
354 <ContactPGPSettings model={model} setModel={setModel} mailSettings={mailSettings} />
356 </CollapsibleContent>
361 <Button type="reset" onClick={rest.onClose}>{c('Action').t`Cancel`}</Button>
364 loading={loadingSave}
365 disabled={loadingSave || loadingPgpSettings}
367 onClick={() => withLoadingSave(handleSubmit(model))}
368 data-testid="email-settings:save"
370 {c('Action').t`Save`}
377 export default ContactEmailSettingsModal;