Merge branch 'fix/sentry-issue' into 'main'
[ProtonMail-WebClient.git] / packages / shared / lib / calendar / import / import.ts
blob47b223119050efe73d94e37dcb09a219e39161ad
1 import { c } from 'ttag';
3 import { CryptoProxy, serverTime } from '@proton/crypto';
4 import { arrayToHexString, binaryStringToArray } from '@proton/crypto/lib/utils';
5 import type { TelemetryReport } from '@proton/shared/lib/api/telemetry';
6 import { TelemetryIcsSurgeryEvents, TelemetryMeasurementGroups } from '@proton/shared/lib/api/telemetry';
7 import { sendMultipleTelemetryReports, sendTelemetryReport } from '@proton/shared/lib/helpers/metrics';
8 import { captureMessage } from '@proton/shared/lib/helpers/sentry';
9 import isTruthy from '@proton/utils/isTruthy';
10 import truncate from '@proton/utils/truncate';
11 import unique from '@proton/utils/unique';
13 import { getEventByUID } from '../../api/calendars';
14 import type { Options as FormatOptions } from '../../date-fns-utc/format';
15 import formatUTC from '../../date-fns-utc/format';
16 import { getSupportedTimezone, toUTCDate } from '../../date/timezone';
17 import { readFileAsString } from '../../helpers/file';
18 import { dateLocale } from '../../i18n';
19 import type { Api, SimpleMap } from '../../interfaces';
20 import type {
21     CalendarEvent,
22     ImportCalendarModel,
23     ImportedEvent,
24     VcalCalendarComponentWithMaybeErrors,
25     VcalErrorComponent,
26     VcalVcalendarWithMaybeErrors,
27     VcalVeventComponent,
28     VcalVtimezoneComponent,
29 } from '../../interfaces/calendar';
30 import { ICAL_METHOD, IMPORT_ERROR_TYPE, MAX_CALENDARS_PAID, MAX_IMPORT_EVENTS } from '../constants';
31 import getComponentFromCalendarEvent from '../getComponentFromCalendarEvent';
32 import { generateVeventHashUID, getNaiveDomainFromUID, getOriginalUID } from '../helper';
33 import { IMPORT_EVENT_ERROR_TYPE, ImportEventError } from '../icsSurgery/ImportEventError';
34 import { getSupportedCalscale } from '../icsSurgery/vcal';
35 import { getLinkedDateTimeProperty, getSupportedEvent, withSupportedDtstamp } from '../icsSurgery/vevent';
36 import { getVeventWithoutErrors, parseVcalendarWithRecoveryAndMaybeErrors, serialize } from '../vcal';
37 import {
38     getHasDtStart,
39     getHasRecurrenceId,
40     getIcalMethod,
41     getIsEventComponent,
42     getIsFreebusyComponent,
43     getIsJournalComponent,
44     getIsPropertyAllDay,
45     getIsTimezoneComponent,
46     getIsTodoComponent,
47     getIsVcalErrorComponent,
48     getPropertyTzid,
49 } from '../vcalHelper';
50 import { ImportFileError } from './ImportFileError';
52 const icsHashesForImportTelemetry = new Set<string>();
53 const icsFilesForIcsParsingTelemetry = new Set<string>();
55 /**
56  * Send telemetry event if we got some fails during import process, so that we know how common errors are, and which error users are facing
57  */
58 export const sendImportErrorTelemetryReport = async ({
59     errors,
60     api,
61     hash,
62 }: {
63     errors: ImportEventError[];
64     api: Api;
65     hash: string;
66 }) => {
67     if (errors.length === 0 || icsHashesForImportTelemetry.has(hash)) {
68         return;
69     }
71     const reports: TelemetryReport[] = errors.map(({ type, componentIdentifiers: { component, prodId, domain } }) => {
72         const dimensions: SimpleMap<string> = {
73             reason: IMPORT_EVENT_ERROR_TYPE[type],
74             component,
75             prodid: prodId,
76             domain,
77         };
79         const report: TelemetryReport = {
80             measurementGroup: TelemetryMeasurementGroups.calendarIcsSurgery,
81             event: TelemetryIcsSurgeryEvents.import,
82             dimensions,
83         };
85         return report;
86     });
88     await sendMultipleTelemetryReports({
89         api: api,
90         reports,
91     });
93     icsHashesForImportTelemetry.add(hash);
96 export const sendTelemetryEventParsingError = (api: Api, error: IMPORT_ERROR_TYPE, filename: string | undefined) => {
97     if (!filename || icsFilesForIcsParsingTelemetry.has(filename)) {
98         return;
99     }
101     void sendTelemetryReport({
102         api,
103         measurementGroup: TelemetryMeasurementGroups.calendarIcsSurgery,
104         event: TelemetryIcsSurgeryEvents.ics_parsing,
105         dimensions: {
106             parsing_error: error,
107         },
108     });
109     icsFilesForIcsParsingTelemetry.add(filename);
112 export const parseIcs = async (ics: File) => {
113     const filename = ics.name;
114     try {
115         const icsAsString = await readFileAsString(ics);
116         const hashPromise = CryptoProxy.computeHash({ algorithm: 'unsafeSHA1', data: binaryStringToArray(icsAsString) })
117             .then((result) => arrayToHexString(result))
118             .catch((error: any) => {
119                 captureMessage('Failed to hash ics', {
120                     level: 'info',
121                     extra: { error },
122                 });
123                 return 'failed_to_hash';
124             });
125         if (!icsAsString) {
126             throw new ImportFileError(IMPORT_ERROR_TYPE.FILE_EMPTY, filename);
127         }
128         const parsedVcalendar = parseVcalendarWithRecoveryAndMaybeErrors(icsAsString) as VcalVcalendarWithMaybeErrors;
129         if (parsedVcalendar.component?.toLowerCase() !== 'vcalendar') {
130             throw new ImportFileError(IMPORT_ERROR_TYPE.INVALID_CALENDAR, filename);
131         }
132         const { method, prodid, calscale, components, 'x-wr-timezone': xWrTimezone } = parsedVcalendar;
133         const supportedCalscale = getSupportedCalscale(calscale);
134         const supportedMethod = getIcalMethod(method);
136         if (!supportedMethod) {
137             throw new ImportFileError(IMPORT_ERROR_TYPE.INVALID_METHOD, filename);
138         }
139         if (!components?.length) {
140             throw new ImportFileError(IMPORT_ERROR_TYPE.NO_EVENTS, filename);
141         }
142         if (components.length > MAX_IMPORT_EVENTS) {
143             throw new ImportFileError(IMPORT_ERROR_TYPE.TOO_MANY_EVENTS, filename);
144         }
146         return {
147             components,
148             calscale: supportedCalscale,
149             xWrTimezone: xWrTimezone?.value,
150             method: supportedMethod,
151             prodId: prodid.value,
152             hashedIcs: await hashPromise,
153         };
154     } catch (e: any) {
155         if (e instanceof ImportFileError) {
156             throw e;
157         }
158         throw new ImportFileError(IMPORT_ERROR_TYPE.FILE_CORRUPTED, filename);
159     }
163  * Get a string that can identify an imported component
164  */
165 export const getComponentIdentifier = (
166     vcalComponent: VcalCalendarComponentWithMaybeErrors | VcalErrorComponent,
167     options: FormatOptions = { locale: dateLocale }
168 ) => {
169     if (getIsVcalErrorComponent(vcalComponent)) {
170         return '';
171     }
172     if (getIsTimezoneComponent(vcalComponent)) {
173         return vcalComponent.tzid.value || '';
174     }
175     const uid = 'uid' in vcalComponent ? vcalComponent.uid?.value : undefined;
176     const originalUid = getOriginalUID(uid);
177     if (originalUid) {
178         return originalUid;
179     }
180     if (getIsEventComponent(vcalComponent)) {
181         const { summary, dtstart } = vcalComponent;
182         const shortTitle = truncate(summary?.value || '');
183         if (shortTitle) {
184             return shortTitle;
185         }
186         if (dtstart?.value) {
187             const format = getIsPropertyAllDay(dtstart) ? 'PP' : 'PPpp';
188             return formatUTC(toUTCDate(dtstart.value), format, options);
189         }
190         return c('Error importing event').t`No UID, title or start time`;
191     }
192     return '';
195 const extractGuessTzid = (components: (VcalCalendarComponentWithMaybeErrors | VcalErrorComponent)[]) => {
196     const vtimezones = components.filter((componentOrError): componentOrError is VcalVtimezoneComponent => {
197         if (getIsVcalErrorComponent(componentOrError)) {
198             return false;
199         }
200         return getIsTimezoneComponent(componentOrError);
201     });
202     if (vtimezones.length === 1) {
203         // we do not have guarantee that the VcalVtimezoneComponent's in vtimezones are propper, so better use optional chaining
204         const guessTzid = vtimezones[0]?.tzid?.value;
205         return guessTzid ? getSupportedTimezone(guessTzid) : undefined;
206     }
209 interface ExtractSupportedEventArgs {
210     method: ICAL_METHOD;
211     prodId: string;
212     vcalComponent: VcalCalendarComponentWithMaybeErrors | VcalErrorComponent;
213     hasXWrTimezone: boolean;
214     formatOptions?: FormatOptions;
215     calendarTzid?: string;
216     guessTzid: string;
217     canImportEventColor?: boolean;
219 export const extractSupportedEvent = async ({
220     method,
221     prodId,
222     vcalComponent: vcalComponentWithMaybeErrors,
223     hasXWrTimezone,
224     formatOptions,
225     calendarTzid,
226     guessTzid,
227     canImportEventColor,
228 }: ExtractSupportedEventArgs) => {
229     const componentId = getComponentIdentifier(vcalComponentWithMaybeErrors, formatOptions);
230     const isInvitation = method !== ICAL_METHOD.PUBLISH;
231     if (getIsVcalErrorComponent(vcalComponentWithMaybeErrors)) {
232         throw new ImportEventError({
233             errorType: IMPORT_EVENT_ERROR_TYPE.EXTERNAL_ERROR,
234             componentIdentifiers: { component: 'error', componentId, prodId, domain: '' },
235             externalError: vcalComponentWithMaybeErrors.error,
236         });
237     }
238     if (getIsTodoComponent(vcalComponentWithMaybeErrors)) {
239         throw new ImportEventError({
240             errorType: IMPORT_EVENT_ERROR_TYPE.TODO_FORMAT,
241             componentIdentifiers: { component: 'vtodo', componentId, prodId, domain: '' },
242         });
243     }
244     if (getIsJournalComponent(vcalComponentWithMaybeErrors)) {
245         throw new ImportEventError({
246             errorType: IMPORT_EVENT_ERROR_TYPE.JOURNAL_FORMAT,
247             componentIdentifiers: { component: 'vjournal', componentId, prodId, domain: '' },
248         });
249     }
250     if (getIsFreebusyComponent(vcalComponentWithMaybeErrors)) {
251         throw new ImportEventError({
252             errorType: IMPORT_EVENT_ERROR_TYPE.FREEBUSY_FORMAT,
253             componentIdentifiers: { component: 'vfreebusy', componentId, prodId, domain: '' },
254         });
255     }
256     if (getIsTimezoneComponent(vcalComponentWithMaybeErrors)) {
257         if (!getSupportedTimezone(vcalComponentWithMaybeErrors.tzid.value)) {
258             throw new ImportEventError({
259                 errorType: IMPORT_EVENT_ERROR_TYPE.TIMEZONE_FORMAT,
260                 componentIdentifiers: { component: 'vtimezone', componentId, prodId, domain: '' },
261             });
262         }
263         throw new ImportEventError({
264             errorType: IMPORT_EVENT_ERROR_TYPE.TIMEZONE_IGNORE,
265             componentIdentifiers: { component: 'vtimezone', componentId, prodId, domain: '' },
266         });
267     }
268     if (!getIsEventComponent(vcalComponentWithMaybeErrors)) {
269         throw new ImportEventError({
270             errorType: IMPORT_EVENT_ERROR_TYPE.WRONG_FORMAT,
271             componentIdentifiers: { component: 'unknown', componentId, prodId, domain: '' },
272         });
273     }
274     const vcalComponent = getVeventWithoutErrors(vcalComponentWithMaybeErrors);
275     if (!getHasDtStart(vcalComponent)) {
276         throw new ImportEventError({
277             errorType: IMPORT_EVENT_ERROR_TYPE.DTSTART_MISSING,
278             componentIdentifiers: { component: 'vevent', componentId, prodId, domain: '' },
279         });
280     }
281     const validVevent = withSupportedDtstamp(vcalComponent, +serverTime());
282     const generateHashUid = !validVevent.uid?.value || isInvitation;
284     if (generateHashUid) {
285         validVevent.uid = {
286             value: await generateVeventHashUID(serialize(vcalComponent), vcalComponent?.uid?.value),
287         };
288     }
290     const componentIdentifiers = {
291         component: 'vevent',
292         componentId,
293         prodId,
294         domain: getNaiveDomainFromUID(validVevent.uid.value),
295     };
297     return getSupportedEvent({
298         vcalVeventComponent: validVevent,
299         hasXWrTimezone,
300         calendarTzid,
301         guessTzid,
302         method,
303         isEventInvitation: false,
304         generatedHashUid: generateHashUid,
305         componentIdentifiers,
306         canImportEventColor,
307     });
310 export const getSupportedEventsOrErrors = async ({
311     components,
312     method,
313     prodId = '',
314     formatOptions,
315     calscale,
316     xWrTimezone,
317     primaryTimezone,
318     canImportEventColor,
319 }: {
320     components: (VcalCalendarComponentWithMaybeErrors | VcalErrorComponent)[];
321     method: ICAL_METHOD;
322     prodId?: string;
323     formatOptions?: FormatOptions;
324     calscale?: string;
325     xWrTimezone?: string;
326     primaryTimezone: string;
327     canImportEventColor?: boolean;
328 }) => {
329     if (calscale?.toLowerCase() !== 'gregorian') {
330         return [
331             new ImportEventError({
332                 errorType: IMPORT_EVENT_ERROR_TYPE.NON_GREGORIAN,
333                 componentIdentifiers: { component: 'vcalendar', componentId: '', prodId, domain: '' },
334             }),
335         ];
336     }
337     const hasXWrTimezone = !!xWrTimezone;
338     const calendarTzid = xWrTimezone ? getSupportedTimezone(xWrTimezone) : undefined;
339     const guessTzid = extractGuessTzid(components) || primaryTimezone;
340     const supportedEvents = await Promise.all(
341         components.map(async (vcalComponent) => {
342             try {
343                 const supportedEvent = await extractSupportedEvent({
344                     method,
345                     prodId,
346                     vcalComponent,
347                     calendarTzid,
348                     hasXWrTimezone,
349                     formatOptions,
350                     guessTzid,
351                     canImportEventColor,
352                 });
353                 return supportedEvent;
354             } catch (e: any) {
355                 return e;
356             }
357         })
358     );
360     return supportedEvents.filter(isTruthy);
364  * Split an array of events into those which have a recurrence id and those which don't
365  */
366 export const splitByRecurrenceId = (events: VcalVeventComponent[]) => {
367     return events.reduce<{
368         withoutRecurrenceId: VcalVeventComponent[];
369         withRecurrenceId: (VcalVeventComponent & Required<Pick<VcalVeventComponent, 'recurrence-id'>>)[];
370     }>(
371         (acc, event) => {
372             if (!getHasRecurrenceId(event)) {
373                 acc.withoutRecurrenceId.push(event);
374             } else {
375                 acc.withRecurrenceId.push(event);
376             }
377             return acc;
378         },
379         { withoutRecurrenceId: [], withRecurrenceId: [] }
380     );
383 export const splitErrors = <T>(events: (T | ImportEventError)[]) => {
384     return events.reduce<{ errors: ImportEventError[]; rest: T[] }>(
385         (acc, event) => {
386             if (event instanceof ImportEventError) {
387                 acc.errors.push(event);
388             } else {
389                 acc.rest.push(event);
390             }
391             return acc;
392         },
393         { errors: [], rest: [] }
394     );
397 // Separate errors that we want to hide
398 export const splitHiddenErrors = (errors: ImportEventError[]) => {
399     return errors.reduce<{ hidden: ImportEventError[]; visible: ImportEventError[] }>(
400         (acc, error) => {
401             if (error.type === IMPORT_EVENT_ERROR_TYPE.NO_OCCURRENCES) {
402                 // Importing an event without occurrences is the same as not importing it
403                 acc.hidden.push(error);
404             } else {
405                 acc.visible.push(error);
406             }
407             return acc;
408         },
409         { hidden: [], visible: [] }
410     );
413 const getParentEventFromApi = async (uid: string, api: Api, calendarId: string) => {
414     try {
415         const { Events } = await api<{ Events: CalendarEvent[] }>({
416             ...getEventByUID({
417                 UID: uid,
418                 Page: 0,
419                 PageSize: MAX_CALENDARS_PAID,
420             }),
421             silence: true,
422         });
423         const [parentEvent] = Events.filter(({ CalendarID }) => CalendarID === calendarId);
424         if (!parentEvent) {
425             return;
426         }
427         const parentComponent = getComponentFromCalendarEvent(parentEvent);
428         if (getHasRecurrenceId(parentComponent)) {
429             // it wouldn't be a parent then
430             return;
431         }
432         return {
433             vcalComponent: parentComponent,
434             calendarEvent: parentEvent,
435         };
436     } catch {
437         return undefined;
438     }
441 interface GetSupportedEventsWithRecurrenceIdArgs {
442     eventsWithRecurrenceId: (VcalVeventComponent & Required<Pick<VcalVeventComponent, 'recurrence-id'>>)[];
443     parentEvents: ImportedEvent[];
444     calendarId: string;
445     api: Api;
446     prodId?: string;
448 export const getSupportedEventsWithRecurrenceId = async ({
449     eventsWithRecurrenceId,
450     parentEvents,
451     calendarId,
452     api,
453     prodId = '',
454 }: GetSupportedEventsWithRecurrenceIdArgs) => {
455     // map uid -> parent event
456     const mapParentEvents = parentEvents.reduce<
457         SimpleMap<{
458             vcalComponent: VcalVeventComponent;
459             calendarEvent: CalendarEvent;
460         }>
461     >((acc, event) => {
462         acc[event.component.uid.value] = {
463             vcalComponent: event.component,
464             calendarEvent: event.response.Response.Event,
465         };
467         return acc;
468     }, {});
469     // complete the map with parent events in the DB
470     const uidsToFetch = unique(
471         eventsWithRecurrenceId.filter(({ uid }) => !mapParentEvents[uid.value]).map(({ uid }) => uid.value)
472     );
473     const result = await Promise.all(uidsToFetch.map((uid) => getParentEventFromApi(uid, api, calendarId)));
474     result.forEach((parentEvent, i) => {
475         mapParentEvents[uidsToFetch[i]] = parentEvent;
476     });
478     return eventsWithRecurrenceId.map((event) => {
479         const uid = event.uid.value;
480         const componentIdentifiers = {
481             component: 'vevent',
482             componentId: getComponentIdentifier(event),
483             prodId,
484             domain: getNaiveDomainFromUID(event.uid.value),
485         };
486         const parentEvent = mapParentEvents[uid];
487         if (!parentEvent) {
488             return new ImportEventError({
489                 errorType: IMPORT_EVENT_ERROR_TYPE.PARENT_EVENT_MISSING,
490                 componentIdentifiers,
491             });
492         }
493         const parentComponent = parentEvent.vcalComponent;
494         if (!parentComponent.rrule) {
495             return new ImportEventError({
496                 errorType: IMPORT_EVENT_ERROR_TYPE.SINGLE_EDIT_UNSUPPORTED,
497                 componentIdentifiers,
498             });
499         }
500         const recurrenceId = event['recurrence-id'];
501         try {
502             const parentDtstart = parentComponent.dtstart;
503             const supportedRecurrenceId = getLinkedDateTimeProperty({
504                 property: recurrenceId,
505                 componentIdentifiers,
506                 linkedIsAllDay: getIsPropertyAllDay(parentDtstart),
507                 linkedTzid: getPropertyTzid(parentDtstart),
508             });
509             return { ...event, 'recurrence-id': supportedRecurrenceId };
510         } catch (e: any) {
511             if (e instanceof ImportEventError) {
512                 return e;
513             }
514             return new ImportEventError({
515                 errorType: IMPORT_EVENT_ERROR_TYPE.VALIDATION_ERROR,
516                 componentIdentifiers,
517             });
518         }
519     });
522 export const extractTotals = (model: ImportCalendarModel) => {
523     const { eventsParsed, totalEncrypted, totalImported, visibleErrors, hiddenErrors } = model;
524     const totalToImport = eventsParsed.length + hiddenErrors.length;
525     const totalToProcess = 2 * totalToImport; // count encryption and submission equivalently for the progress
526     const totalEncryptedFake = totalEncrypted + hiddenErrors.length;
527     const totalImportedFake = totalImported + hiddenErrors.length;
528     const totalVisibleErrors = visibleErrors.length;
529     const totalProcessed = totalEncryptedFake + totalImportedFake + totalVisibleErrors;
530     return {
531         totalToImport,
532         totalToProcess,
533         totalImported: totalImportedFake,
534         totalProcessed,
535     };