Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / packages / shared / lib / calendar / attendees.ts
blobf4f9756b2ee1fc0ec185c770124d567bc9ed842d
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 NO_CANONICAL_EMAIL_ERROR = 'No canonical email provided';
26 export const generateAttendeeToken = async (normalizedEmail: string, uid: string) => {
27     const uidEmail = `${uid}${normalizedEmail}`;
28     const byteArray = binaryStringToArray(uidEmail);
29     const hash = await CryptoProxy.computeHash({ algorithm: 'unsafeSHA1', data: byteArray });
30     return arrayToHexString(hash);
33 export const toApiPartstat = (partstat?: string) => {
34     if (partstat === ICAL_ATTENDEE_STATUS.TENTATIVE) {
35         return ATTENDEE_STATUS_API.TENTATIVE;
36     }
37     if (partstat === ICAL_ATTENDEE_STATUS.ACCEPTED) {
38         return ATTENDEE_STATUS_API.ACCEPTED;
39     }
40     if (partstat === ICAL_ATTENDEE_STATUS.DECLINED) {
41         return ATTENDEE_STATUS_API.DECLINED;
42     }
43     return ATTENDEE_STATUS_API.NEEDS_ACTION;
46 export const toIcsPartstat = (partstat?: ATTENDEE_STATUS_API) => {
47     if (partstat === ATTENDEE_STATUS_API.TENTATIVE) {
48         return ICAL_ATTENDEE_STATUS.TENTATIVE;
49     }
50     if (partstat === ATTENDEE_STATUS_API.ACCEPTED) {
51         return ICAL_ATTENDEE_STATUS.ACCEPTED;
52     }
53     if (partstat === ATTENDEE_STATUS_API.DECLINED) {
54         return ICAL_ATTENDEE_STATUS.DECLINED;
55     }
56     return ICAL_ATTENDEE_STATUS.NEEDS_ACTION;
59 export const fromInternalAttendee = ({
60     parameters: { 'x-pm-token': token = '', partstat, ...restParameters } = {},
61     ...rest
62 }: VcalAttendeeProperty) => {
63     return {
64         attendee: {
65             parameters: {
66                 ...restParameters,
67                 'x-pm-token': token,
68             },
69             ...rest,
70         },
71         clear: {
72             token,
73             status: toApiPartstat(partstat),
74         },
75     };
78 export const toInternalAttendee = (
79     { attendee: attendees = [] }: Pick<VcalVeventComponent, 'attendee'>,
80     clear: Attendee[] = []
81 ): VcalAttendeeProperty[] => {
82     return attendees.map((attendee) => {
83         if (!attendee.parameters) {
84             return attendee;
85         }
86         const token = attendee.parameters['x-pm-token'];
87         const extra = clear.find(({ Token }) => Token === token);
88         if (!token || !extra) {
89             return attendee;
90         }
91         const partstat = toIcsPartstat(extra.Status);
92         return {
93             ...attendee,
94             parameters: {
95                 ...attendee.parameters,
96                 partstat,
97             },
98         };
99     });
102 export const getAttendeeEmail = (attendee: VcalAttendeeProperty | VcalOrganizerProperty) => {
103     const { cn, email } = attendee.parameters || {};
104     const emailTo = getEmailTo(attendee.value);
105     if (validateEmailAddress(emailTo)) {
106         return emailTo;
107     }
108     if (email && validateEmailAddress(email)) {
109         return email;
110     }
111     if (cn && validateEmailAddress(cn)) {
112         return cn;
113     }
114     return emailTo;
117 export const withPartstat = (attendee: VcalAttendeeProperty, partstat?: ICAL_ATTENDEE_STATUS) => ({
118     ...attendee,
119     parameters: {
120         ...attendee.parameters,
121         partstat,
122     },
125 export const modifyAttendeesPartstat = (
126     attendees: VcalAttendeeProperty[],
127     partstatMap: SimpleMap<ICAL_ATTENDEE_STATUS>
128 ) => {
129     const emailsToModify = Object.keys(partstatMap);
130     return attendees.map((attendee) => {
131         const email = getAttendeeEmail(attendee);
132         if (!emailsToModify.includes(email)) {
133             return attendee;
134         }
135         return withPartstat(attendee, partstatMap[email]);
136     });
139 export const getSupportedOrganizer = (organizer: VcalOrganizerProperty) => {
140     const { parameters: { cn } = {} } = organizer;
141     const emailAddress = getAttendeeEmail(organizer);
142     const supportedOrganizer: RequireSome<VcalAttendeeProperty, 'parameters'> = {
143         value: buildMailTo(emailAddress),
144         parameters: {
145             cn: truncatePossiblyQuotedString(cn ?? emailAddress, CONTACT_NAME_MAX_LENGTH),
146         },
147     };
149     return supportedOrganizer;
152 export const getSupportedAttendee = (attendee: VcalAttendeeProperty) => {
153     const { parameters: { cn, role, partstat, rsvp, 'x-pm-token': token } = {} } = attendee;
154     const emailAddress = getAttendeeEmail(attendee);
155     const supportedAttendee: RequireSome<VcalAttendeeProperty, 'parameters'> = {
156         value: buildMailTo(emailAddress),
157         parameters: {
158             cn: truncatePossiblyQuotedString(cn ?? emailAddress, CONTACT_NAME_MAX_LENGTH),
159         },
160     };
161     const normalizedUppercasedRole = normalize(role).toUpperCase();
163     if (
164         normalizedUppercasedRole === ICAL_ATTENDEE_ROLE.REQUIRED ||
165         normalizedUppercasedRole === ICAL_ATTENDEE_ROLE.OPTIONAL
166     ) {
167         supportedAttendee.parameters.role = normalizedUppercasedRole;
168     }
170     if (normalize(rsvp) === 'true') {
171         supportedAttendee.parameters.rsvp = 'TRUE';
172     }
174     if (partstat) {
175         supportedAttendee.parameters.partstat = getAttendeePartstat(attendee);
176     }
178     if (token?.length === 40) {
179         supportedAttendee.parameters['x-pm-token'] = token;
180     }
182     return supportedAttendee;
185 export const getCanonicalEmails = async (
186     attendees: VcalAttendeeProperty[] = [],
187     getCanonicalEmailsMap: GetCanonicalEmailsMap
188 ) => {
189     return Object.values(await getCanonicalEmailsMap(attendees.map(unary(getAttendeeEmail)))).filter(isTruthy);
192 export const withPmAttendees = async (
193     vevent: VcalVeventComponent,
194     getCanonicalEmailsMap: GetCanonicalEmailsMap,
195     ignoreErrors = false
196 ): Promise<VcalPmVeventComponent> => {
197     const { uid, attendee: vcalAttendee } = vevent;
198     if (!vcalAttendee?.length) {
199         return omit(vevent, ['attendee']);
200     }
201     const attendeesWithEmail = vcalAttendee.map((attendee) => {
202         const emailAddress = getAttendeeEmail(attendee);
203         return {
204             attendee,
205             emailAddress,
206         };
207     });
208     const emailsWithoutToken = attendeesWithEmail
209         .filter(({ attendee }) => !attendee.parameters?.['x-pm-token'])
210         .map(({ emailAddress }) => emailAddress);
211     const canonicalEmailMap = await getCanonicalEmailsMap(emailsWithoutToken);
213     const pmAttendees = await Promise.all(
214         attendeesWithEmail.map(async ({ attendee, emailAddress }) => {
215             const supportedAttendee = getSupportedAttendee(attendee);
216             if (getAttendeeHasToken(supportedAttendee)) {
217                 return supportedAttendee;
218             }
219             const canonicalEmail = canonicalEmailMap[emailAddress];
220             if (!canonicalEmail && !ignoreErrors) {
221                 throw new Error(NO_CANONICAL_EMAIL_ERROR);
222             }
223             // If the participant has an invalid email and we ignore errors, we fall back to the provided email address
224             const token = await generateAttendeeToken(canonicalEmail || emailAddress, uid.value);
225             return {
226                 ...supportedAttendee,
227                 parameters: {
228                     ...supportedAttendee.parameters,
229                     'x-pm-token': token,
230                 },
231             };
232         })
233     );
234     return {
235         ...vevent,
236         attendee: pmAttendees,
237     };
240 export const getEquivalentAttendees = (attendees?: VcalAttendeeProperty[]) => {
241     if (!attendees?.length) {
242         return;
243     }
244     if (getAttendeesHaveToken(attendees)) {
245         const attendeesWithToken = attendees.map((attendee) => ({
246             token: attendee.parameters['x-pm-token'],
247             email: getAttendeeEmail(attendee),
248         }));
249         const equivalentAttendees = groupWith((a, b) => a.token === b.token, attendeesWithToken).map((group) =>
250             group.map(({ email }) => email)
251         );
252         return equivalentAttendees.length < attendees.length
253             ? equivalentAttendees.filter((group) => group.length > 1)
254             : undefined;
255     }
256     // not all attendees have token, so we're gonna canonicalize emails and compare based on that
257     const attendeesWithCanonicalEmail = attendees.map((attendee) => {
258         const email = getAttendeeEmail(attendee);
259         const canonicalEmail = canonicalizeEmailByGuess(email);
260         return { email, canonicalEmail };
261     });
262     const equivalentAttendees = groupWith(
263         (a, b) => a.canonicalEmail === b.canonicalEmail,
264         attendeesWithCanonicalEmail
265     ).map((group) => group.map(({ email }) => email));
266     return equivalentAttendees.length < attendees.length
267         ? equivalentAttendees.filter((group) => group.length > 1)
268         : undefined;
271 const { REQUIRED } = ICAL_ATTENDEE_ROLE;
272 const { TRUE } = ICAL_ATTENDEE_RSVP;
273 const { NEEDS_ACTION } = ICAL_ATTENDEE_STATUS;
275 export const emailToAttendee = (email: string): AttendeeModel => ({
276     email,
277     cn: email,
278     role: REQUIRED,
279     partstat: NEEDS_ACTION,
280     rsvp: TRUE,