Use same lock values as mobile clients
[ProtonMail-WebClient.git] / packages / shared / lib / calendar / mailIntegration / invite.ts
blob01862ef2f794b03ff98a2ab122075e748555758d
1 import { c } from 'ttag';
3 import { getIsAddressExternal } from '@proton/shared/lib/helpers/address';
4 import { unescape } from '@proton/shared/lib/sanitize/escape';
5 import isTruthy from '@proton/utils/isTruthy';
6 import unary from '@proton/utils/unary';
8 import { MIME_TYPES } from '../../constants';
9 import { addDays, format as formatUTC } from '../../date-fns-utc';
10 import type { Options } from '../../date-fns-utc/format';
11 import { formatTimezoneOffset, getTimezoneOffset, toUTCDate } from '../../date/timezone';
12 import {
13     buildMailTo,
14     canonicalizeEmail,
15     canonicalizeEmailByGuess,
16     canonicalizeInternalEmail,
17 } from '../../helpers/email';
18 import { omit, pick } from '../../helpers/object';
19 import { getCurrentUnixTimestamp } from '../../helpers/time';
20 import { dateLocale } from '../../i18n';
21 import type { Address } from '../../interfaces';
22 import type {
23     Attendee,
24     CalendarEvent,
25     CalendarSettings,
26     Participant,
27     VcalAttendeeProperty,
28     VcalComponentKeys,
29     VcalOrganizerProperty,
30     VcalStringProperty,
31     VcalValarmComponent,
32     VcalVcalendar,
33     VcalVeventComponent,
34     VcalVtimezoneComponent,
35 } from '../../interfaces/calendar';
36 import type { ContactEmail } from '../../interfaces/contacts';
37 import type { GetVTimezonesMap } from '../../interfaces/hooks/GetVTimezonesMap';
38 import type { RequireSome } from '../../interfaces/utils';
39 import { getSupportedPlusAlias } from '../../mail/addresses';
40 import { MESSAGE_FLAGS } from '../../mail/constants';
41 import { RE_PREFIX, formatSubject } from '../../mail/messages';
42 import { getAttendeeEmail, toIcsPartstat } from '../attendees';
43 import {
44     ICAL_ALARM_ACTION,
45     ICAL_ATTENDEE_STATUS,
46     ICAL_METHOD,
47     NOTIFICATION_TYPE_API,
48     RECURRING_TYPES,
49 } from '../constants';
50 import { getSelfAddressData } from '../deserialize';
51 import { getDisplayTitle } from '../helper';
52 import { getSupportedStringValue } from '../icsSurgery/vcal';
53 import { getIsRruleEqual } from '../recurrence/rruleEqual';
54 import { stripAllTags } from '../sanitize';
55 import { fromTriggerString, serialize } from '../vcal';
56 import { getAllDayInfo, getHasModifiedDateTimes, getIsEquivalentAttendee, propertyToUTCDate } from '../vcalConverter';
57 import {
58     getAttendeePartstat,
59     getAttendeeRole,
60     getHasRecurrenceId,
61     getIsAlarmComponent,
62     getPropertyTzid,
63 } from '../vcalHelper';
64 import {
65     getIsAllDay,
66     getIsEventCancelled,
67     getSequence,
68     withDtstamp,
69     withSummary,
70     withoutRedundantDtEnd,
71     withoutRedundantRrule,
72 } from '../veventHelper';
74 export const getParticipantHasAddressID = (
75     participant: Participant
76 ): participant is RequireSome<Participant, 'addressID'> => {
77     return !!participant.addressID;
80 export const getParticipant = ({
81     participant,
82     contactEmails,
83     selfAddress,
84     selfAttendee,
85     emailTo,
86     index,
87     calendarAttendees,
88     xYahooUserStatus,
89 }: {
90     participant: VcalAttendeeProperty | VcalOrganizerProperty;
91     contactEmails: ContactEmail[];
92     selfAddress?: Address;
93     selfAttendee?: VcalAttendeeProperty;
94     emailTo?: string;
95     index?: number;
96     calendarAttendees?: Attendee[];
97     xYahooUserStatus?: string;
98 }): Participant => {
99     const emailAddress = getAttendeeEmail(participant);
100     const canonicalInternalEmail = canonicalizeInternalEmail(emailAddress);
101     const canonicalEmail = canonicalizeEmailByGuess(emailAddress);
102     const isSelf = selfAddress && canonicalizeInternalEmail(selfAddress.Email) === canonicalInternalEmail;
103     const isYou = emailTo ? canonicalizeInternalEmail(emailTo) === canonicalInternalEmail : isSelf;
104     const contact = contactEmails.find(({ Email }) => canonicalizeEmail(Email) === canonicalEmail);
105     const participantName = participant?.parameters?.cn || emailAddress;
106     const displayName = (isSelf && selfAddress?.DisplayName) || contact?.Name || participantName;
107     const result: Participant = {
108         vcalComponent: participant,
109         name: participantName,
110         emailAddress,
111         partstat: getAttendeePartstat(participant, xYahooUserStatus),
112         displayName: isYou ? c('Participant name').t`You` : displayName,
113         displayEmail: emailAddress,
114     };
115     const { role, email, 'x-pm-token': token } = (participant as VcalAttendeeProperty).parameters || {};
116     const calendarAttendee = token ? calendarAttendees?.find(({ Token }) => Token === token) : undefined;
117     if (role) {
118         result.role = getAttendeeRole(participant);
119     }
120     if (email) {
121         result.displayEmail = email;
122     }
123     if (token) {
124         result.token = token;
125     }
126     if (calendarAttendee) {
127         result.updateTime = calendarAttendee.UpdateTime;
128         result.attendeeID = calendarAttendee.ID;
129     }
130     if (selfAddress && selfAttendee && isSelf) {
131         result.addressID = selfAddress.ID;
132         // Use Proton form of the email address (important for sending email)
133         result.emailAddress = getSupportedPlusAlias({
134             selfAttendeeEmail: getAttendeeEmail(selfAttendee),
135             selfAddressEmail: selfAddress.Email,
136         });
137         // Use Proton name when sending out the email
138         result.name = selfAddress.DisplayName || participantName;
139     }
140     if (index !== undefined) {
141         result.attendeeIndex = index;
142     }
143     return result;
147  * Build ad-hoc participant data for a party crasher
148  * (to fake a party crasher actually being in the ICS)
149  */
150 export const buildPartyCrasherParticipantData = (
151     originalTo: string,
152     ownAddresses: Address[],
153     contactEmails: ContactEmail[],
154     attendees: VcalAttendeeProperty[]
155 ): { participant?: Participant; selfAttendee: VcalAttendeeProperty; selfAddress: Address } | undefined => {
156     let isCatchAllPartyCrasher = false;
157     const selfInternalAddresses = ownAddresses.filter((address) => !getIsAddressExternal(address));
159     const canonicalizedOriginalTo = canonicalizeInternalEmail(originalTo);
160     let selfAddress = selfInternalAddresses.find(
161         ({ Email }) => canonicalizeInternalEmail(Email) === canonicalizedOriginalTo
162     );
164     if (!selfAddress) {
165         const catchAllAddress = selfInternalAddresses.find(({ CatchAll }) => CatchAll);
166         if (catchAllAddress) {
167             // if any address is catch-all, that will be detected as party crasher
168             isCatchAllPartyCrasher = true;
169             selfAddress = catchAllAddress;
170         } else {
171             return;
172         }
173     }
175     const fakeOriginalTo = isCatchAllPartyCrasher ? selfAddress.Email : originalTo;
176     const selfAttendee: VcalAttendeeProperty = {
177         value: buildMailTo(fakeOriginalTo),
178         parameters: {
179             cn: originalTo,
180             partstat: ICAL_ATTENDEE_STATUS.NEEDS_ACTION,
181         },
182     };
184     return {
185         participant: getParticipant({
186             participant: selfAttendee,
187             selfAddress,
188             selfAttendee,
189             contactEmails,
190             index: attendees.length,
191             emailTo: fakeOriginalTo,
192         }),
193         selfAttendee,
194         selfAddress,
195     };
198 interface CreateInviteVeventParams {
199     method: ICAL_METHOD;
200     attendeesTo?: VcalAttendeeProperty[];
201     vevent: VcalVeventComponent;
202     keepDtstamp?: boolean;
205 export const createInviteVevent = ({ method, attendeesTo, vevent, keepDtstamp }: CreateInviteVeventParams) => {
206     if ([ICAL_METHOD.REPLY, ICAL_METHOD.CANCEL].includes(method) && attendeesTo?.length) {
207         const propertiesToKeepForCancel: (keyof VcalVeventComponent)[] = ['x-pm-shared-event-id'];
208         const propertiesToKeepForReply: (keyof VcalVeventComponent)[] = ['x-pm-proton-reply', 'exdate'];
209         const keepDtStampProperty: (keyof VcalVeventComponent)[] = ['dtstamp'];
211         // only put RFC-mandatory fields to make reply as short as possible
212         // rrule, summary and location are also included for a better UI in the external provider widget
213         const propertiesToKeep: (keyof VcalVeventComponent)[] = [
214             'uid',
215             'dtstart',
216             'dtend',
217             'sequence',
218             'recurrence-id',
219             'organizer',
220             'rrule',
221             'location',
222             'summary',
223             ...(keepDtstamp ? keepDtStampProperty : []),
224             ...(method === ICAL_METHOD.CANCEL ? propertiesToKeepForCancel : []),
225             ...(method === ICAL_METHOD.REPLY ? propertiesToKeepForReply : []),
226         ];
228         const attendee = attendeesTo.map(({ value, parameters }) => {
229             const { partstat } = parameters || {};
230             if (method === ICAL_METHOD.REPLY) {
231                 if (!partstat) {
232                     throw new Error('Cannot reply without participant status');
233                 }
234                 return {
235                     value,
236                     parameters: { partstat },
237                 };
238             }
239             return { value };
240         });
242         const veventWithoutRedundantDtEnd = withoutRedundantDtEnd(
243             withDtstamp({
244                 ...pick(vevent, propertiesToKeep),
245                 component: 'vevent',
246                 attendee,
247             })
248         );
250         return method === ICAL_METHOD.REPLY
251             ? withoutRedundantRrule(veventWithoutRedundantDtEnd)
252             : veventWithoutRedundantDtEnd;
253     }
255     if (method === ICAL_METHOD.REQUEST) {
256         // strip alarms
257         const propertiesToOmit: (keyof VcalVeventComponent)[] = ['components', 'x-pm-proton-reply', 'color'];
258         // use current time as dtstamp unless indicated otherwise
259         if (!keepDtstamp) {
260             propertiesToOmit.push('dtstamp');
261         }
262         // SUMMARY is mandatory in a REQUEST ics
263         const veventWithSummary = withSummary(vevent);
264         return withoutRedundantDtEnd(withDtstamp(omit(veventWithSummary, propertiesToOmit) as VcalVeventComponent));
265     }
268 interface CreateInviteIcsParams {
269     method: ICAL_METHOD;
270     prodId: string;
271     vevent: VcalVeventComponent;
272     attendeesTo?: VcalAttendeeProperty[];
273     vtimezones?: VcalVtimezoneComponent[];
274     sharedEventID?: string;
275     keepDtstamp?: boolean;
278 export const createInviteIcs = ({
279     method,
280     prodId,
281     attendeesTo,
282     vevent,
283     vtimezones,
284     keepDtstamp,
285 }: CreateInviteIcsParams): string => {
286     // use current time as dtstamp
287     const inviteVevent = createInviteVevent({ method, vevent, attendeesTo, keepDtstamp });
288     if (!inviteVevent) {
289         throw new Error('Invite vevent failed to be created');
290     }
291     const inviteVcal: RequireSome<VcalVcalendar, 'components'> = {
292         component: 'vcalendar',
293         components: [inviteVevent],
294         prodid: { value: prodId },
295         version: { value: '2.0' },
296         method: { value: method },
297         calscale: { value: 'GREGORIAN' },
298     };
300     if (vtimezones?.length) {
301         inviteVcal.components = [...vtimezones, ...inviteVcal.components];
302     }
304     return serialize(inviteVcal);
307 export const findAttendee = (email: string, attendees: VcalAttendeeProperty[] = []) => {
308     // treat all emails as internal. This is not fully correct (TO BE IMPROVED),
309     // but it's better to have some false positives rather than many false negatives
310     const canonicalEmail = canonicalizeInternalEmail(email);
311     const index = attendees.findIndex(
312         (attendee) => canonicalizeInternalEmail(getAttendeeEmail(attendee)) === canonicalEmail
313     );
314     const attendee = index !== -1 ? attendees[index] : undefined;
315     return { index, attendee };
318 export const getVeventWithDefaultCalendarAlarms = (vevent: VcalVeventComponent, calendarSettings: CalendarSettings) => {
319     const { components } = vevent;
321     const isAllDay = getIsAllDay(vevent);
322     const notifications = isAllDay
323         ? calendarSettings.DefaultFullDayNotifications
324         : calendarSettings.DefaultPartDayNotifications;
325     const valarmComponents = notifications.map<VcalValarmComponent>(({ Trigger, Type }) => ({
326         component: 'valarm',
327         action: { value: Type === NOTIFICATION_TYPE_API.EMAIL ? ICAL_ALARM_ACTION.EMAIL : ICAL_ALARM_ACTION.DISPLAY },
328         trigger: { value: fromTriggerString(Trigger) },
329     }));
331     return {
332         ...vevent,
333         components: components ? components.concat(valarmComponents) : valarmComponents,
334     };
337 export const getInvitedVeventWithAlarms = ({
338     vevent,
339     partstat,
340     calendarSettings,
341     oldHasDefaultNotifications,
342     oldPartstat,
343 }: {
344     vevent: VcalVeventComponent;
345     partstat: ICAL_ATTENDEE_STATUS;
346     calendarSettings?: CalendarSettings;
347     oldHasDefaultNotifications?: boolean;
348     oldPartstat?: ICAL_ATTENDEE_STATUS;
349 }) => {
350     const { components } = vevent;
351     const alarmComponents = components?.filter((component) => getIsAlarmComponent(component));
352     const otherComponents = components?.filter((component) => !getIsAlarmComponent(component));
354     if ([ICAL_ATTENDEE_STATUS.DECLINED, ICAL_ATTENDEE_STATUS.NEEDS_ACTION].includes(partstat)) {
355         // remove all alarms in this case
356         if (otherComponents?.length) {
357             return {
358                 vevent: { ...vevent, components: otherComponents },
359                 hasDefaultNotifications: false,
360             };
361         }
362         return {
363             vevent: { ...vevent, components: [] },
364             hasDefaultNotifications: false,
365         };
366     }
367     const leaveAlarmsUntouched = oldPartstat
368         ? [ICAL_ATTENDEE_STATUS.ACCEPTED, ICAL_ATTENDEE_STATUS.TENTATIVE].includes(oldPartstat) ||
369           !!alarmComponents?.length
370         : false;
371     if (leaveAlarmsUntouched) {
372         return {
373             vevent,
374             hasDefaultNotifications: oldHasDefaultNotifications || false,
375         };
376     }
377     // otherwise add default calendar alarms
378     if (!calendarSettings) {
379         throw new Error('Cannot retrieve calendar default notifications');
380     }
382     return {
383         vevent: getVeventWithDefaultCalendarAlarms(vevent, calendarSettings),
384         hasDefaultNotifications: true,
385     };
388 export const getSelfAttendeeToken = (vevent?: VcalVeventComponent, addresses: Address[] = []) => {
389     if (!vevent?.attendee) {
390         return;
391     }
392     const { selfAddress, selfAttendeeIndex } = getSelfAddressData({
393         organizer: vevent.organizer,
394         attendees: vevent.attendee,
395         addresses,
396     });
397     if (!selfAddress || selfAttendeeIndex === undefined) {
398         return;
399     }
400     return vevent.attendee[selfAttendeeIndex].parameters?.['x-pm-token'];
403 export const generateVtimezonesComponents = async (
404     { dtstart, dtend, 'recurrence-id': recurrenceId, exdate = [] }: VcalVeventComponent,
405     getVTimezones: GetVTimezonesMap
406 ): Promise<VcalVtimezoneComponent[]> => {
407     const timezones = [dtstart, dtend, recurrenceId, ...exdate]
408         .filter(isTruthy)
409         .map(unary(getPropertyTzid))
410         .filter(isTruthy);
412     const vtimezonesObject = await getVTimezones(timezones);
413     return Object.values(vtimezonesObject)
414         .filter(isTruthy)
415         .map(({ vtimezone }) => vtimezone);
418 const getFormattedDateInfo = (vevent: VcalVeventComponent, options: Options = { locale: dateLocale }) => {
419     const { dtstart, dtend } = vevent;
420     const { isAllDay, isSingleAllDay } = getAllDayInfo(dtstart, dtend);
421     if (isAllDay) {
422         return {
423             formattedStart: formatUTC(toUTCDate(dtstart.value), 'cccc PPP', options),
424             formattedEnd: dtend ? formatUTC(addDays(toUTCDate(dtend.value), -1), 'cccc PPP', options) : undefined,
425             isAllDay,
426             isSingleAllDay,
427         };
428     }
429     const formattedStartDateTime = formatUTC(toUTCDate(dtstart.value), 'cccc PPPp', options);
430     const formattedEndDateTime = dtend ? formatUTC(toUTCDate(dtend.value), 'cccc PPPp', options) : undefined;
431     const { offset: startOffset } = getTimezoneOffset(propertyToUTCDate(dtstart), getPropertyTzid(dtstart) || 'UTC');
432     const { offset: endOffset } = dtend
433         ? getTimezoneOffset(propertyToUTCDate(dtend), getPropertyTzid(dtend) || 'UTC')
434         : { offset: 0 };
435     const formattedStartOffset = `GMT${formatTimezoneOffset(startOffset)}`;
436     const formattedEndOffset = `GMT${formatTimezoneOffset(endOffset)}`;
437     return {
438         formattedStart: `${formattedStartDateTime} (${formattedStartOffset})`,
439         formattedEnd: formattedEndDateTime ? `${formattedEndDateTime} (${formattedEndOffset})` : undefined,
440         isAllDay,
441         isSingleAllDay,
442     };
445 export const generateEmailSubject = ({
446     method,
447     vevent,
448     isCreateEvent,
449     dateFormatOptions,
450 }: {
451     method: ICAL_METHOD;
452     vevent: VcalVeventComponent;
453     isCreateEvent?: boolean;
454     dateFormatOptions?: Options;
455 }) => {
456     const { formattedStart, isSingleAllDay } = getFormattedDateInfo(withoutRedundantDtEnd(vevent), dateFormatOptions);
457     const { REQUEST, CANCEL, REPLY } = ICAL_METHOD;
459     if (method === REQUEST) {
460         const isSingleEdit = getHasRecurrenceId(vevent);
461         if (isSingleAllDay) {
462             return isCreateEvent && !isSingleEdit
463                 ? c('Email subject').t`Invitation for an event on ${formattedStart}`
464                 : c('Email subject').t`Update for an event on ${formattedStart}`;
465         }
467         return isCreateEvent && !isSingleEdit
468             ? c('Email subject').t`Invitation for an event starting on ${formattedStart}`
469             : c('Email subject').t`Update for an event starting on ${formattedStart}`;
470     }
472     if (method === CANCEL) {
473         return isSingleAllDay
474             ? c('Email subject').t`Cancellation of an event on ${formattedStart}`
475             : c('Email subject').t`Cancellation of an event starting on ${formattedStart}`;
476     }
478     if (method === REPLY) {
479         return isSingleAllDay
480             ? formatSubject(c('Email subject').t`Invitation for an event on ${formattedStart}`, RE_PREFIX)
481             : formatSubject(c('Email subject').t`Invitation for an event starting on ${formattedStart}`, RE_PREFIX);
482     }
484     throw new Error('Unexpected method');
487 const getWhenText = (vevent: VcalVeventComponent, dateFormatOptions?: Options) => {
488     const { formattedStart, formattedEnd, isAllDay, isSingleAllDay } = getFormattedDateInfo(vevent, dateFormatOptions);
489     if (isAllDay) {
490         return isSingleAllDay || !formattedEnd
491             ? c('Email body for invitation (date part)').t`TIME:
492 ${formattedStart} (all day)`
493             : c('Email body for invitation (date part)').t`TIME:
494 ${formattedStart} - ${formattedEnd}`;
495     }
496     return formattedEnd
497         ? c('Email body for invitation (date part)').t`TIME:
498 ${formattedStart} - ${formattedEnd}`
499         : c('Email body for invitation (date part)').t`TIME:
500 ${formattedStart}`;
503 const buildUpdatedFieldText = (updatedBodyText: string, updatedFieldText: string, field: string) => {
504     // translator: text to display in the message body of an updated event when a certain field has been removed
505     const removedFieldText = c('Email body for invitation (event details part)').t`Removed`;
507     // If field is updated and empty, the user removed it.
508     // In that case we want to display a text to let the user know that the field has been removed.
509     const hasRemovedField = updatedFieldText === '';
511     if (updatedBodyText === '') {
512         return hasRemovedField
513             ? `${field}:
514 ${removedFieldText}`
515             : updatedFieldText;
516     }
517     return hasRemovedField
518         ? `${updatedBodyText}
520 ${field}:
521 ${removedFieldText}`
522         : `${updatedBodyText}
524 ${updatedFieldText}`;
527 const getUpdateEmailBodyText = ({
528     vevent,
529     oldVevent,
530     eventTitle,
531     whenText,
532     locationText,
533     descriptionText,
534 }: {
535     vevent: VcalVeventComponent;
536     oldVevent: VcalVeventComponent;
537     eventTitle: string;
538     whenText: string;
539     locationText: string;
540     descriptionText: string;
541 }) => {
542     const hasSameTime = !getHasModifiedDateTimes(vevent, oldVevent);
543     const hasSameTitle = getSupportedStringValue(vevent.summary) === getSupportedStringValue(oldVevent.summary);
544     const hasSameLocation = getSupportedStringValue(vevent.location) === getSupportedStringValue(oldVevent.location);
545     const hasSameDescription =
546         getSupportedStringValue(vevent.description) === getSupportedStringValue(oldVevent.description);
548     let updatedBodyText = '';
549     if (!hasSameTitle) {
550         updatedBodyText = buildUpdatedFieldText(updatedBodyText, eventTitle, 'TITLE');
551     }
552     if (!hasSameTime) {
553         updatedBodyText = buildUpdatedFieldText(updatedBodyText, whenText, 'TIME');
554     }
555     if (!hasSameLocation) {
556         updatedBodyText = buildUpdatedFieldText(updatedBodyText, locationText, 'LOCATION');
557     }
558     if (!hasSameDescription) {
559         updatedBodyText = buildUpdatedFieldText(updatedBodyText, descriptionText, 'DESCRIPTION');
560     }
561     return updatedBodyText;
564 const getEmailBodyTexts = (
565     vevent: VcalVeventComponent,
566     oldVevent?: VcalVeventComponent,
567     dateFormatOptions?: Options
568 ) => {
569     const { summary, location, description } = vevent;
570     const eventTitle = getDisplayTitle(summary?.value);
571     const eventLocation = location?.value;
572     const eventDescription = description?.value;
574     const whenText = getWhenText(vevent, dateFormatOptions);
575     const locationText = eventLocation
576         ? c('Email body for invitation (location part)').t`LOCATION:
577 ${eventLocation}`
578         : '';
579     const descriptionText = eventDescription
580         ? c('Email body for description (description part)').t`DESCRIPTION:
581 ${eventDescription}`
582         : '';
583     const locationAndDescriptionText =
584         locationText && descriptionText
585             ? `${locationText}
587 ${descriptionText}`
588             : `${locationText || descriptionText}`;
589     const eventDetailsText = locationAndDescriptionText
590         ? `${whenText}
592 ${locationAndDescriptionText}`
593         : `${whenText}`;
595     const titleText = `TITLE:
596 ${eventTitle}`;
597     const updateEventDetailsText = oldVevent
598         ? getUpdateEmailBodyText({
599               vevent,
600               oldVevent,
601               eventTitle: titleText,
602               whenText,
603               locationText,
604               descriptionText,
605           })
606         : undefined;
608     return { eventTitle, eventDetailsText, updateEventDetailsText };
611 export const generateEmailBody = ({
612     method,
613     vevent,
614     oldVevent,
615     isCreateEvent,
616     partstat,
617     emailAddress,
618     options,
619     recurringType,
620 }: {
621     method: ICAL_METHOD;
622     vevent: VcalVeventComponent;
623     oldVevent?: VcalVeventComponent;
624     isCreateEvent?: boolean;
625     emailAddress?: string;
626     partstat?: ICAL_ATTENDEE_STATUS;
627     options?: Options;
628     recurringType?: RECURRING_TYPES;
629 }) => {
630     const { eventTitle, eventDetailsText, updateEventDetailsText } = getEmailBodyTexts(
631         withoutRedundantDtEnd(vevent),
632         oldVevent ? withoutRedundantDtEnd(oldVevent) : undefined,
633         options
634     );
635     const hasUpdatedText = updateEventDetailsText && updateEventDetailsText !== '';
637     if (method === ICAL_METHOD.REQUEST) {
638         if (getHasRecurrenceId(vevent)) {
639             return hasUpdatedText
640                 ? c('Email body for invitation').t`This event occurrence was updated. Here's what changed:
642 ${updateEventDetailsText}`
643                 : c('Email body for invitation').t`This event occurrence was updated.`;
644         }
645         if (recurringType === RECURRING_TYPES.ALL) {
646             return hasUpdatedText
647                 ? c('Email body for invitation').t`All events in this series were updated. Here's what changed:
649 ${updateEventDetailsText}`
650                 : c('Email body for invitation').t`All events in this series were updated.`;
651         }
652         if (isCreateEvent) {
653             return c('Email body for invitation').t`You are invited to ${eventTitle}.
655 ${eventDetailsText}`;
656         }
657         return hasUpdatedText
658             ? c('Email body for invitation').t`This event was updated. Here's what changed:
660 ${updateEventDetailsText}`
661             : c('Email body for invitation').t`This event was updated.`;
662     }
663     if (method === ICAL_METHOD.CANCEL) {
664         if (getHasRecurrenceId(vevent)) {
665             return c('Email body for invitation').t`This event occurrence was canceled.`;
666         }
667         return c('Email body for invitation').t`${eventTitle} was canceled.`;
668     }
669     if (method === ICAL_METHOD.REPLY) {
670         if (!partstat || !emailAddress) {
671             throw new Error('Missing parameters for reply body');
672         }
673         if (partstat === ICAL_ATTENDEE_STATUS.ACCEPTED) {
674             return c('Email body for response to invitation')
675                 .t`${emailAddress} accepted your invitation to ${eventTitle}`;
676         }
677         if (partstat === ICAL_ATTENDEE_STATUS.TENTATIVE) {
678             return c('Email body for response to invitation')
679                 .t`${emailAddress} tentatively accepted your invitation to ${eventTitle}`;
680         }
681         if (partstat === ICAL_ATTENDEE_STATUS.DECLINED) {
682             return c('Email body for response to invitation')
683                 .t`${emailAddress} declined your invitation to ${eventTitle}`;
684         }
685         throw new Error('Unanswered partstat');
686     }
687     throw new Error('Unexpected method');
690 export const getIcsMessageWithPreferences = (globalSign: number) => ({
691     MIMEType: MIME_TYPES.PLAINTEXT,
692     Flags: globalSign ? MESSAGE_FLAGS.FLAG_SIGN : undefined,
695 export const getHasUpdatedInviteData = ({
696     newVevent,
697     oldVevent,
698     hasModifiedDateTimes,
699     hasModifiedRrule,
700 }: {
701     newVevent: VcalVeventComponent;
702     oldVevent?: VcalVeventComponent;
703     hasModifiedDateTimes?: boolean;
704     hasModifiedRrule?: boolean;
705 }) => {
706     if (!oldVevent) {
707         return false;
708     }
709     const hasUpdatedDateTimes = hasModifiedDateTimes ?? getHasModifiedDateTimes(newVevent, oldVevent);
711     const keys: VcalComponentKeys[] = ['summary', 'description', 'location'];
712     const hasUpdatedTitleDescriptionOrLocation = keys.some((key) => {
713         const newValue = getSupportedStringValue(newVevent[key] as VcalStringProperty);
714         const oldValue = getSupportedStringValue(oldVevent[key] as VcalStringProperty);
716         // Sanitize to better diff detection, and unescape characters
717         // `oldvalue` is the original event value, so it can contain HTML tags
718         // `newValue` is supposed to be already sanitized
719         // Always doing the computation because sometimes the new values is undefined, but not the old one
720         const cleanedNewValue = stripAllTags(unescape(newValue || '')).trim();
721         const cleanedOldValue = stripAllTags(unescape(oldValue || '')).trim();
722         return cleanedNewValue !== cleanedOldValue;
723     });
725     const hasUpdatedRrule = hasModifiedRrule ?? !getIsRruleEqual(newVevent.rrule, oldVevent.rrule);
726     return hasUpdatedDateTimes || hasUpdatedTitleDescriptionOrLocation || hasUpdatedRrule;
729 export const getInviteVeventWithUpdatedParstats = (
730     newVevent: VcalVeventComponent,
731     oldVevent: VcalVeventComponent,
732     method?: ICAL_METHOD
733 ) => {
734     if (method === ICAL_METHOD.REQUEST && getSequence(newVevent) > getSequence(oldVevent)) {
735         if (!newVevent.attendee?.length) {
736             return { ...newVevent };
737         }
738         const withResetPartstatAttendees = newVevent.attendee.map((attendee) => ({
739             ...attendee,
740             parameters: {
741                 ...attendee.parameters,
742                 partstat: ICAL_ATTENDEE_STATUS.NEEDS_ACTION,
743             },
744         }));
745         return { ...newVevent, attendee: withResetPartstatAttendees };
746     }
747     return { ...newVevent };
750 export const getResetPartstatActions = (
751     singleEdits: CalendarEvent[],
752     token: string,
753     partstat: ICAL_ATTENDEE_STATUS
754 ) => {
755     const updateTime = getCurrentUnixTimestamp();
756     const updateActions = singleEdits
757         .map((event) => {
758             if (getIsEventCancelled(event)) {
759                 // no need to reset the partsat as it should have been done already
760                 return;
761             }
762             const selfAttendee = event.Attendees.find(({ Token }) => Token === token);
763             if (!selfAttendee) {
764                 return;
765             }
766             const oldPartstat = toIcsPartstat(selfAttendee.Status);
767             if ([ICAL_ATTENDEE_STATUS.NEEDS_ACTION, partstat].includes(oldPartstat)) {
768                 // no need to reset the partstat as it's already reset or it coincides with the new partstat
769                 return;
770             }
771             return {
772                 attendeeID: selfAttendee.ID,
773                 eventID: event.ID,
774                 calendarID: event.CalendarID,
775                 updateTime,
776                 partstat: ICAL_ATTENDEE_STATUS.NEEDS_ACTION,
777                 color: event.Color ? event.Color : undefined,
778             };
779         })
780         .filter(isTruthy);
781     const updatePartstatActions = updateActions.map((action) => omit(action, ['color']));
782     const updatePersonalPartActions = updateActions
783         .map(({ eventID, calendarID, color }) => ({ eventID, calendarID, color }))
784         .filter(isTruthy);
786     return { updatePartstatActions, updatePersonalPartActions };
789 export const getHasNonCancelledSingleEdits = (singleEdits: CalendarEvent[]) => {
790     return singleEdits.some((event) => !getIsEventCancelled(event));
793 export const getMustResetPartstat = (singleEdits: CalendarEvent[], token?: string, partstat?: ICAL_ATTENDEE_STATUS) => {
794     if (!token || !partstat) {
795         return false;
796     }
797     return singleEdits.some((event) => {
798         if (getIsEventCancelled(event)) {
799             return false;
800         }
801         const selfAttendee = event.Attendees.find(({ Token }) => Token === token);
802         if (!selfAttendee) {
803             return false;
804         }
805         const oldPartstat = toIcsPartstat(selfAttendee.Status);
806         if ([ICAL_ATTENDEE_STATUS.NEEDS_ACTION, partstat].includes(oldPartstat)) {
807             return false;
808         }
809         return true;
810     });
813 export const getHasModifiedAttendees = ({
814     veventIcs,
815     veventApi,
816     attendeeIcs,
817     attendeeApi,
818 }: {
819     veventIcs: VcalVeventComponent;
820     veventApi: VcalVeventComponent;
821     attendeeIcs: Participant;
822     attendeeApi: Participant;
823 }) => {
824     const { attendee: attendeesIcs } = veventIcs;
825     const { attendee: attendeesApi } = veventApi;
826     if (!attendeesIcs) {
827         return !!attendeesApi;
828     }
829     if (!attendeesApi || attendeesApi.length !== attendeesIcs.length) {
830         return true;
831     }
832     // We check if attendees other than the invitation attendees have been modified
833     const otherAttendeesIcs = attendeesIcs.filter(
834         (attendee) => canonicalizeEmail(getAttendeeEmail(attendee)) !== canonicalizeEmail(attendeeIcs.emailAddress)
835     );
836     const otherAttendeesApi = attendeesApi.filter(
837         (attendee) => canonicalizeEmail(getAttendeeEmail(attendee)) !== canonicalizeEmail(attendeeApi.emailAddress)
838     );
839     return otherAttendeesIcs.reduce((acc, attendee) => {
840         if (acc === true) {
841             return true;
842         }
843         const index = otherAttendeesApi.findIndex((oldAttendee) => getIsEquivalentAttendee(oldAttendee, attendee));
844         if (index === -1) {
845             return true;
846         }
847         otherAttendeesApi.splice(index, 1);
848         return false;
849     }, false);