Use same lock values as mobile clients
[ProtonMail-WebClient.git] / packages / shared / lib / calendar / attendees.ts
blob6973b9a39d784e0d63da4677ed19a6a6922f863f
1 import { CryptoProxy } from '@proton/crypto';
2 import { arrayToHexString, binaryStringToArray } from '@proton/crypto/lib/utils';
3 import groupWith from '@proton/utils/groupWith';
4 import isTruthy from '@proton/utils/isTruthy';
5 import unary from '@proton/utils/unary';
7 import { CONTACT_NAME_MAX_LENGTH } from '../contacts/constants';
8 import { buildMailTo, canonicalizeEmailByGuess, getEmailTo, validateEmailAddress } from '../helpers/email';
9 import { omit } from '../helpers/object';
10 import { normalize, truncatePossiblyQuotedString } from '../helpers/string';
11 import type {
12     Attendee,
13     AttendeeModel,
14     VcalAttendeeProperty,
15     VcalOrganizerProperty,
16     VcalPmVeventComponent,
17     VcalVeventComponent,
18 } from '../interfaces/calendar';
19 import type { GetCanonicalEmailsMap } from '../interfaces/hooks/GetCanonicalEmailsMap';
20 import type { RequireSome, SimpleMap } from '../interfaces/utils';
21 import { ATTENDEE_STATUS_API, ICAL_ATTENDEE_ROLE, ICAL_ATTENDEE_RSVP, ICAL_ATTENDEE_STATUS } from './constants';
22 import { getAttendeeHasToken, getAttendeePartstat, getAttendeesHaveToken } from './vcalHelper';
24 export const generateAttendeeToken = async (normalizedEmail: string, uid: string) => {
25     const uidEmail = `${uid}${normalizedEmail}`;
26     const byteArray = binaryStringToArray(uidEmail);
27     const hash = await CryptoProxy.computeHash({ algorithm: 'unsafeSHA1', data: byteArray });
28     return arrayToHexString(hash);
31 export const toApiPartstat = (partstat?: string) => {
32     if (partstat === ICAL_ATTENDEE_STATUS.TENTATIVE) {
33         return ATTENDEE_STATUS_API.TENTATIVE;
34     }
35     if (partstat === ICAL_ATTENDEE_STATUS.ACCEPTED) {
36         return ATTENDEE_STATUS_API.ACCEPTED;
37     }
38     if (partstat === ICAL_ATTENDEE_STATUS.DECLINED) {
39         return ATTENDEE_STATUS_API.DECLINED;
40     }
41     return ATTENDEE_STATUS_API.NEEDS_ACTION;
44 export const toIcsPartstat = (partstat?: ATTENDEE_STATUS_API) => {
45     if (partstat === ATTENDEE_STATUS_API.TENTATIVE) {
46         return ICAL_ATTENDEE_STATUS.TENTATIVE;
47     }
48     if (partstat === ATTENDEE_STATUS_API.ACCEPTED) {
49         return ICAL_ATTENDEE_STATUS.ACCEPTED;
50     }
51     if (partstat === ATTENDEE_STATUS_API.DECLINED) {
52         return ICAL_ATTENDEE_STATUS.DECLINED;
53     }
54     return ICAL_ATTENDEE_STATUS.NEEDS_ACTION;
57 export const fromInternalAttendee = ({
58     parameters: { 'x-pm-token': token = '', partstat, ...restParameters } = {},
59     ...rest
60 }: VcalAttendeeProperty) => {
61     return {
62         attendee: {
63             parameters: {
64                 ...restParameters,
65                 'x-pm-token': token,
66             },
67             ...rest,
68         },
69         clear: {
70             token,
71             status: toApiPartstat(partstat),
72         },
73     };
76 export const toInternalAttendee = (
77     { attendee: attendees = [] }: Pick<VcalVeventComponent, 'attendee'>,
78     clear: Attendee[] = []
79 ): VcalAttendeeProperty[] => {
80     return attendees.map((attendee) => {
81         if (!attendee.parameters) {
82             return attendee;
83         }
84         const token = attendee.parameters['x-pm-token'];
85         const extra = clear.find(({ Token }) => Token === token);
86         if (!token || !extra) {
87             return attendee;
88         }
89         const partstat = toIcsPartstat(extra.Status);
90         return {
91             ...attendee,
92             parameters: {
93                 ...attendee.parameters,
94                 partstat,
95             },
96         };
97     });
100 export const getAttendeeEmail = (attendee: VcalAttendeeProperty | VcalOrganizerProperty) => {
101     const { cn, email } = attendee.parameters || {};
102     const emailTo = getEmailTo(attendee.value);
103     if (validateEmailAddress(emailTo)) {
104         return emailTo;
105     }
106     if (email && validateEmailAddress(email)) {
107         return email;
108     }
109     if (cn && validateEmailAddress(cn)) {
110         return cn;
111     }
112     return emailTo;
115 export const withPartstat = (attendee: VcalAttendeeProperty, partstat?: ICAL_ATTENDEE_STATUS) => ({
116     ...attendee,
117     parameters: {
118         ...attendee.parameters,
119         partstat,
120     },
123 export const modifyAttendeesPartstat = (
124     attendees: VcalAttendeeProperty[],
125     partstatMap: SimpleMap<ICAL_ATTENDEE_STATUS>
126 ) => {
127     const emailsToModify = Object.keys(partstatMap);
128     return attendees.map((attendee) => {
129         const email = getAttendeeEmail(attendee);
130         if (!emailsToModify.includes(email)) {
131             return attendee;
132         }
133         return withPartstat(attendee, partstatMap[email]);
134     });
137 export const getSupportedOrganizer = (organizer: VcalOrganizerProperty) => {
138     const { parameters: { cn } = {} } = organizer;
139     const emailAddress = getAttendeeEmail(organizer);
140     const supportedOrganizer: RequireSome<VcalAttendeeProperty, 'parameters'> = {
141         value: buildMailTo(emailAddress),
142         parameters: {
143             cn: truncatePossiblyQuotedString(cn ?? emailAddress, CONTACT_NAME_MAX_LENGTH),
144         },
145     };
147     return supportedOrganizer;
150 export const getSupportedAttendee = (attendee: VcalAttendeeProperty) => {
151     const { parameters: { cn, role, partstat, rsvp, 'x-pm-token': token } = {} } = attendee;
152     const emailAddress = getAttendeeEmail(attendee);
153     const supportedAttendee: RequireSome<VcalAttendeeProperty, 'parameters'> = {
154         value: buildMailTo(emailAddress),
155         parameters: {
156             cn: truncatePossiblyQuotedString(cn ?? emailAddress, CONTACT_NAME_MAX_LENGTH),
157         },
158     };
159     const normalizedUppercasedRole = normalize(role).toUpperCase();
161     if (
162         normalizedUppercasedRole === ICAL_ATTENDEE_ROLE.REQUIRED ||
163         normalizedUppercasedRole === ICAL_ATTENDEE_ROLE.OPTIONAL
164     ) {
165         supportedAttendee.parameters.role = normalizedUppercasedRole;
166     }
168     if (normalize(rsvp) === 'true') {
169         supportedAttendee.parameters.rsvp = 'TRUE';
170     }
172     if (partstat) {
173         supportedAttendee.parameters.partstat = getAttendeePartstat(attendee);
174     }
176     if (token?.length === 40) {
177         supportedAttendee.parameters['x-pm-token'] = token;
178     }
180     return supportedAttendee;
183 export const getCanonicalEmails = async (
184     attendees: VcalAttendeeProperty[] = [],
185     getCanonicalEmailsMap: GetCanonicalEmailsMap
186 ) => {
187     return Object.values(await getCanonicalEmailsMap(attendees.map(unary(getAttendeeEmail)))).filter(isTruthy);
190 export const withPmAttendees = async (
191     vevent: VcalVeventComponent,
192     getCanonicalEmailsMap: GetCanonicalEmailsMap,
193     ignoreErrors = false
194 ): Promise<VcalPmVeventComponent> => {
195     const { uid, attendee: vcalAttendee } = vevent;
196     if (!vcalAttendee?.length) {
197         return omit(vevent, ['attendee']);
198     }
199     const attendeesWithEmail = vcalAttendee.map((attendee) => {
200         const emailAddress = getAttendeeEmail(attendee);
201         return {
202             attendee,
203             emailAddress,
204         };
205     });
206     const emailsWithoutToken = attendeesWithEmail
207         .filter(({ attendee }) => !attendee.parameters?.['x-pm-token'])
208         .map(({ emailAddress }) => emailAddress);
209     const canonicalEmailMap = await getCanonicalEmailsMap(emailsWithoutToken);
211     const pmAttendees = await Promise.all(
212         attendeesWithEmail.map(async ({ attendee, emailAddress }) => {
213             const supportedAttendee = getSupportedAttendee(attendee);
214             if (getAttendeeHasToken(supportedAttendee)) {
215                 return supportedAttendee;
216             }
217             const canonicalEmail = canonicalEmailMap[emailAddress];
218             if (!canonicalEmail && !ignoreErrors) {
219                 throw new Error('No canonical email provided');
220             }
221             // If the participant has an invalid email and we ignore errors, we fall back to the provided email address
222             const token = await generateAttendeeToken(canonicalEmail || emailAddress, uid.value);
223             return {
224                 ...supportedAttendee,
225                 parameters: {
226                     ...supportedAttendee.parameters,
227                     'x-pm-token': token,
228                 },
229             };
230         })
231     );
232     return {
233         ...vevent,
234         attendee: pmAttendees,
235     };
238 export const getEquivalentAttendees = (attendees?: VcalAttendeeProperty[]) => {
239     if (!attendees?.length) {
240         return;
241     }
242     if (getAttendeesHaveToken(attendees)) {
243         const attendeesWithToken = attendees.map((attendee) => ({
244             token: attendee.parameters['x-pm-token'],
245             email: getAttendeeEmail(attendee),
246         }));
247         const equivalentAttendees = groupWith((a, b) => a.token === b.token, attendeesWithToken).map((group) =>
248             group.map(({ email }) => email)
249         );
250         return equivalentAttendees.length < attendees.length
251             ? equivalentAttendees.filter((group) => group.length > 1)
252             : undefined;
253     }
254     // not all attendees have token, so we're gonna canonicalize emails and compare based on that
255     const attendeesWithCanonicalEmail = attendees.map((attendee) => {
256         const email = getAttendeeEmail(attendee);
257         const canonicalEmail = canonicalizeEmailByGuess(email);
258         return { email, canonicalEmail };
259     });
260     const equivalentAttendees = groupWith(
261         (a, b) => a.canonicalEmail === b.canonicalEmail,
262         attendeesWithCanonicalEmail
263     ).map((group) => group.map(({ email }) => email));
264     return equivalentAttendees.length < attendees.length
265         ? equivalentAttendees.filter((group) => group.length > 1)
266         : undefined;
269 const { REQUIRED } = ICAL_ATTENDEE_ROLE;
270 const { TRUE } = ICAL_ATTENDEE_RSVP;
271 const { NEEDS_ACTION } = ICAL_ATTENDEE_STATUS;
273 export const emailToAttendee = (email: string): AttendeeModel => ({
274     email,
275     cn: email,
276     role: REQUIRED,
277     partstat: NEEDS_ACTION,
278     rsvp: TRUE,