1 import { useMemo, useState } from 'react';
3 import { c } from 'ttag';
5 import { useGetAddressKeys } from '@proton/account/addressKeys/hooks';
6 import { useAddresses } from '@proton/account/addresses/hooks';
7 import { useGetUser } from '@proton/account/user/hooks';
8 import { useGetUserKeys } from '@proton/account/userKeys/hooks';
9 import { Button, Href } from '@proton/atoms';
10 import Form from '@proton/components/components/form/Form';
11 import Icon from '@proton/components/components/icon/Icon';
12 import type { ModalProps } from '@proton/components/components/modalTwo/Modal';
13 import ModalTwo from '@proton/components/components/modalTwo/Modal';
14 import ModalTwoContent from '@proton/components/components/modalTwo/ModalContent';
15 import ModalTwoFooter from '@proton/components/components/modalTwo/ModalFooter';
16 import ModalTwoHeader from '@proton/components/components/modalTwo/ModalHeader';
17 import Option from '@proton/components/components/option/Option';
18 import SelectTwo from '@proton/components/components/selectTwo/SelectTwo';
19 import InputFieldTwo from '@proton/components/components/v2/field/InputField';
20 import useFormErrors from '@proton/components/components/v2/useFormErrors';
21 import useKTVerifier from '@proton/components/containers/keyTransparency/useKTVerifier';
22 import useApi from '@proton/components/hooks/useApi';
23 import useAuthentication from '@proton/components/hooks/useAuthentication';
24 import useEventManager from '@proton/components/hooks/useEventManager';
25 import useGetPublicKeysForInbox from '@proton/components/hooks/useGetPublicKeysForInbox';
26 import useNotifications from '@proton/components/hooks/useNotifications';
27 import type { PrivateKeyReference, PublicKeyReference } from '@proton/crypto/lib';
28 import { CryptoProxy } from '@proton/crypto/lib';
29 import useLoading from '@proton/hooks/useLoading';
30 import { useContactEmails } from '@proton/mail/contactEmails/hooks';
31 import type { SetupForwardingParameters } from '@proton/shared/lib/api/forwardings';
32 import { setupForwarding, updateForwardingFilter } from '@proton/shared/lib/api/forwardings';
33 import { ADDRESS_RECEIVE, KEYGEN_CONFIGS, KEYGEN_TYPES, RECIPIENT_TYPES } from '@proton/shared/lib/constants';
34 import { emailValidator, requiredValidator } from '@proton/shared/lib/helpers/formValidators';
35 import { getKnowledgeBaseUrl } from '@proton/shared/lib/helpers/url';
36 import type { Address, DecryptedKey, OutgoingAddressForwarding } from '@proton/shared/lib/interfaces';
37 import { ForwardingType } from '@proton/shared/lib/interfaces';
38 import type { ContactEmail } from '@proton/shared/lib/interfaces/contacts';
39 import { addAddressKeysProcess, getEmailFromKey, splitKeys } from '@proton/shared/lib/keys';
40 import illustration from '@proton/styles/assets/img/illustrations/forward-email-verification.svg';
41 import uniqueBy from '@proton/utils/uniqueBy';
43 import useAddressFlags from '../../hooks/useAddressFlags';
44 import type { Condition } from '../filters/interfaces';
45 import { FilterStatement } from '../filters/interfaces';
46 import ForwardConditions from './ForwardConditions';
47 import { getInternalParametersPrivate, getSieveParameters, getSieveTree } from './helpers';
49 interface Props extends ModalProps {
50 forward?: OutgoingAddressForwarding;
65 forwardeeEmail: string;
66 forwardeePublicKey?: PublicKeyReference;
67 forwarderKey?: PrivateKeyReference;
68 forwarderAddressKeys?: DecryptedKey[];
69 keySupportE2EEForwarding?: boolean;
71 statement: FilterStatement;
72 conditions: Condition[];
75 const getTitle = (model: Model) => {
76 const { step } = model;
78 if (step === Step.Setup) {
79 return c('email_forwarding_2023: Title').t`Set up forwarding`;
82 if (step === Step.Verification) {
83 return c('email_forwarding_2023: Title').t`Request confirmation`;
89 const getDefaultModel = ({ forward, addresses }: { addresses: Address[]; forward?: OutgoingAddressForwarding }) => {
90 const isEditing = !!forward;
91 const { statement, conditions } = isEditing
92 ? getSieveParameters(forward.Filter?.Tree || [])
93 : { statement: FilterStatement.ALL, conditions: [] };
95 const [firstAddress] = addresses;
98 addressID: isEditing ? forward.ForwarderAddressID : firstAddress?.ID || '',
99 forwardeeEmail: isEditing ? forward.ForwardeeEmail : '',
105 const compareContactEmailByEmail = (a: ContactEmail, b: ContactEmail) => {
106 return a.Email.localeCompare(b.Email);
109 const keyGenConfig = KEYGEN_CONFIGS[KEYGEN_TYPES.CURVE25519];
111 const ForwardModal = ({ forward, onClose, ...rest }: Props) => {
112 const isEditing = !!forward;
113 const [addresses = []] = useAddresses();
114 const [contactEmails = []] = useContactEmails();
115 const contactEmailsSorted = useMemo(() => {
116 const uniqueEmails = uniqueBy(contactEmails, ({ Email }) => Email.toLowerCase());
117 const sortedEmails = [...uniqueEmails].sort(compareContactEmailByEmail);
120 const api = useApi();
121 const getUser = useGetUser();
122 const silentApi = <T,>(config: any) => api<T>({ ...config, silence: true });
123 const { keyTransparencyVerify, keyTransparencyCommit } = useKTVerifier(silentApi, getUser);
124 const authentication = useAuthentication();
125 const getPublicKeysForInbox = useGetPublicKeysForInbox();
126 const getAddressKeys = useGetAddressKeys();
127 const getUserKeys = useGetUserKeys();
128 const { createNotification } = useNotifications();
129 const { call } = useEventManager();
130 const { validator, onFormSubmit } = useFormErrors();
131 const [loading, withLoading] = useLoading();
132 const filteredAddresses = addresses.filter(({ Receive }) => Receive === ADDRESS_RECEIVE.RECEIVE_YES);
133 const [model, setModel] = useState<Model>(getDefaultModel({ forward, addresses: filteredAddresses }));
134 const inputsDisabled = model.loading || isEditing;
135 const forwarderAddress = addresses.find(({ ID }) => ID === model.addressID);
136 const forwarderEmail = forwarderAddress?.Email || '';
137 const addressFlags = useAddressFlags(forwarderAddress);
138 const boldForwardeeEmail = <strong key="forwardee-email">{model.forwardeeEmail}</strong>;
139 const boldForwarderEmail = <strong key="forwarder-email">{forwarderEmail}</strong>;
140 const learnMoreLink = (
141 <Href href={getKnowledgeBaseUrl('/email-forwarding')}>{c('email_forwarding_2023: Link').t`Learn more`}</Href>
144 const generateNewKey = async () => {
145 if (!forwarderAddress) {
146 throw new Error('No address');
149 if (!model.forwarderAddressKeys) {
150 throw new Error('No forwarder address keys');
153 const userKeys = await getUserKeys();
154 const [newKey] = await addAddressKeysProcess({
159 address: forwarderAddress,
160 addressKeys: model.forwarderAddressKeys,
161 keyPassword: authentication.getPassword(),
162 keyTransparencyVerify,
164 const { privateKey: forwarderKey } = newKey;
165 const [forwarderAddressKeys] = await Promise.all([
166 getAddressKeys(model.addressID),
167 keyTransparencyCommit(userKeys),
172 forwarderAddressKeys,
176 const handleEdit = async () => {
179 updateForwardingFilter(
182 conditions: model.conditions,
183 statement: model.statement,
184 email: model.forwardeeEmail,
186 forward.Filter?.Version || 2
191 createNotification({ text: c('email_forwarding_2023: Success').t`Changes saved` });
195 const handleSetup = async () => {
196 const [forwarderAddressKeys, forwardeeKeysConfig] = await Promise.all([
197 getAddressKeys(model.addressID),
198 getPublicKeysForInbox({ email: model.forwardeeEmail, lifetime: 0 }),
201 // Abort the setup if e.g. the given address is internal but does not exist
202 const apiErrors = forwardeeKeysConfig.Errors || [];
203 if (apiErrors.length > 0) {
204 apiErrors.forEach((error: string) => {
205 createNotification({ text: error, type: 'error' });
210 const isInternal = forwardeeKeysConfig.RecipientType === RECIPIENT_TYPES.TYPE_INTERNAL;
211 const isExternal = forwardeeKeysConfig.RecipientType === RECIPIENT_TYPES.TYPE_EXTERNAL;
212 const { privateKeys } = splitKeys(forwarderAddressKeys);
213 const [forwarderKey] = privateKeys;
214 const keySupportE2EEForwarding = await CryptoProxy.doesKeySupportE2EEForwarding({ forwarderKey });
215 let forwardeePublicKey: PublicKeyReference | undefined;
216 let forwardeeEmailFromPublicKey: string | undefined;
219 // While forwarding could be setup with generic catch-all addresses, we disallow this as the catch-all address case is triggered
220 // if the forwardee is a private subuser who has yet to login (i.e.has missing keys).
221 // In such case, the admin public keys are temporarily returned instead, meaning that E2EE forwarding will be (permanently) setup with the admin, rather
222 // than the subuser, which is undesirable.
223 if (forwardeeKeysConfig.isCatchAll) {
224 createNotification({ text: 'This address cannot be used as forwarding recipient', type: 'error' });
227 const [primaryForwardeeKey] = forwardeeKeysConfig.publicKeys;
228 forwardeePublicKey = await CryptoProxy.importPublicKey({
229 armoredKey: primaryForwardeeKey.armoredKey,
231 forwardeeEmailFromPublicKey = getEmailFromKey(forwardeePublicKey);
236 forwarderAddressKeys,
237 keySupportE2EEForwarding,
238 keyErrors: forwardeeKeysConfig.Errors,
241 forwardeeEmail: forwardeeEmailFromPublicKey || model.forwardeeEmail,
244 step: Step.Verification,
248 const handleVerification = async () => {
249 // Disable encryption if the email is external
250 if (model.isExternal && addressFlags?.encryptionDisabled === false) {
251 await addressFlags?.handleSetAddressFlags(true, addressFlags?.expectSignatureDisabled);
254 const params: SetupForwardingParameters = {
255 ForwarderAddressID: model.addressID,
256 ForwardeeEmail: model.forwardeeEmail,
257 Type: model.isInternal ? ForwardingType.InternalEncrypted : ForwardingType.ExternalUnencrypted,
259 conditions: model.conditions,
260 statement: model.statement,
261 email: model.forwardeeEmail,
263 Version: forward?.Filter?.Version || 2,
265 let requireNewKey = false;
266 if (model.isInternal && forwarderAddress?.Keys && model.forwardeePublicKey && model.forwarderKey) {
267 let forwarderKey = model.forwarderKey;
269 if (!model.keySupportE2EEForwarding) {
270 // The forwarding material generation will fail if the address key is e.g. RSA instead of ECC 25519
271 // So we generate automatically a new ECC 25519 key for the address
272 const newProperties = await generateNewKey();
273 // Save the new key in case something goes wrong later
278 forwarderKey = newProperties.forwarderKey;
281 const { activationToken, forwardeeKey, proxyInstances } = await getInternalParametersPrivate(
283 [{ email: model.forwardeeEmail, name: model.forwardeeEmail }],
284 model.forwardeePublicKey
286 params.ForwardeePrivateKey = forwardeeKey;
287 params.ActivationToken = activationToken;
288 params.ProxyInstances = proxyInstances;
291 await api(setupForwarding(params));
294 createNotification({ text: c('email_forwarding_2023: Success').t`Email sent to ${model.forwardeeEmail}.` });
298 text: c('email_forwarding_2023: Success')
299 .t`A new encryption key has been generated for ${forwarderEmail}.`,
304 const handleSubmit = async () => {
305 if (loading || !onFormSubmit()) {
313 if (model.step === Step.Setup) {
314 return handleSetup();
317 if (model.step === Step.Verification) {
318 return handleVerification();
322 const handleBack = () => {
323 setModel({ ...model, step: Step.Setup });
330 onSubmit={() => withLoading(handleSubmit())}
336 <ModalTwoHeader title={getTitle(model)} />
338 {model.step === Step.Setup && (
343 label={c('email_forwarding_2023: Label').t`Forward from`}
344 value={model.addressID}
345 onValue={(value: unknown) => setModel({ ...model, addressID: value as string })}
346 disabled={inputsDisabled}
347 disabledOnlyField={inputsDisabled}
350 {(isEditing ? addresses : filteredAddresses).map(({ ID, Email, Receive }) => (
355 disabled={Receive !== ADDRESS_RECEIVE.RECEIVE_YES}
363 label={c('email_forwarding_2023: Label').t`Forward to`}
364 placeholder={c('email_forwarding_2023: Placeholder').t`Enter email address`}
365 disabled={inputsDisabled}
366 disabledOnlyField={inputsDisabled}
368 list="contact-emails"
371 requiredValidator(model.forwardeeEmail),
372 emailValidator(model.forwardeeEmail),
374 value={model.forwardeeEmail}
375 onValue={(value: string) => setModel({ ...model, forwardeeEmail: value })}
378 {contactEmailsSorted.length ? (
379 <datalist id="contact-emails">
380 {contactEmailsSorted?.map((contactEmail) => (
381 <option key={contactEmail.ID} value={contactEmail.Email}>
387 <hr className="my-4" />
389 conditions={model.conditions}
390 statement={model.statement}
391 validator={validator}
392 onChangeStatement={(newStatement) => setModel({ ...model, statement: newStatement })}
393 onChangeConditions={(newConditions) => setModel({ ...model, conditions: newConditions })}
397 {model.step === Step.Verification && (
399 <div className="text-center">
400 <img src={illustration} alt="" />
401 <p>{c('email_forwarding_2023: Info')
402 .jt`A confirmation email will be sent to ${boldForwardeeEmail}`}</p>
403 <p>{c('email_forwarding_2023: Info')
404 .t`Forwarding to this address will become active once the recipient accepts the forwarding.`}</p>
406 {model.isExternal ? (
407 <div className="border rounded-lg p-4 flex flex-nowrap items-center mb-3">
408 <Icon name="exclamation-circle" className="shrink-0 color-danger" />
409 <p className="text-sm color-weak flex-1 pl-4 my-0">
410 {c('email_forwarding_2023: Info')
411 .jt`Forwarding to an address without end-to-end encryption will disable end-to-end encryption for your ${boldForwarderEmail} address, but zero-access encryption remains enabled. ${learnMoreLink}`}
415 {model.isExternal || model.keySupportE2EEForwarding ? null : (
416 <div className="border rounded-lg p-4 flex flex-nowrap items-center mb-3">
417 <Icon name="exclamation-circle" className="shrink-0 color-danger" />
418 <p className="text-sm color-weak flex-1 pl-4 my-0">
419 {c('email_forwarding_2023: Info')
420 .jt`A new encryption key will be generated for ${boldForwarderEmail}.`}
424 {model?.keyErrors?.length ? (
425 <div className="border rounded-lg p-4 flex flex-nowrap items-center">
426 <Icon name="exclamation-circle" className="shrink-0 color-danger" />
427 <p className="text-sm color-weak flex-1 pl-4 my-0">{model.keyErrors.join(' ')}</p>
434 {model.step === Step.Setup && (
436 <Button disabled={loading} type="reset">{c('email_forwarding_2023: Action').t`Cancel`}</Button>
437 <Button loading={loading} color="norm" type="submit">
439 ? c('email_forwarding_2023: Action').t`Save`
440 : c('email_forwarding_2023: Action').t`Next`}
444 {model.step === Step.Verification && (
446 <Button onClick={handleBack}>{c('email_forwarding_2023: Action').t`Back`}</Button>
447 <Button loading={loading} color="norm" type="submit">{c('email_forwarding_2023: Action')
448 .t`Send confirmation email`}</Button>
456 export default ForwardModal;