1 import { addDays, fromUnixTime } from 'date-fns';
3 import type { EventComponentIdentifiers } from '@proton/shared/lib/calendar/icsSurgery/interface';
4 import { getClosestProtonColor } from '@proton/shared/lib/colors';
5 import { validateEmailAddress } from '@proton/shared/lib/helpers/email';
6 import truncate from '@proton/utils/truncate';
7 import unique from '@proton/utils/unique';
9 import type { RequireOnly, RequireSome } from '../../../lib/interfaces';
10 import { DAY } from '../../constants';
11 import { convertUTCDateTimeToZone, fromUTCDate, getSupportedTimezone } from '../../date/timezone';
13 IcalJSDateOrDateTimeProperty,
15 VcalDateOrDateTimeProperty,
18 VcalFloatingDateTimeProperty,
20 } from '../../interfaces/calendar';
21 import { dedupeAlarmsWithNormalizedTriggers } from '../alarms';
22 import { getAttendeeEmail, getSupportedAttendee, getSupportedOrganizer } from '../attendees';
23 import { ICAL_METHOD, MAX_CHARS_API, MAX_ICAL_SEQUENCE } from '../constants';
24 import { getIsDateOutOfBounds, getIsWellFormedDateOrDateTime, getSupportedUID } from '../helper';
25 import { getHasConsistentRrule, getHasOccurrences, getSupportedRrule } from '../recurrence/rrule';
26 import { durationToMilliseconds } from '../vcal';
31 getDateTimePropertyInDifferentTimezone,
33 } from '../vcalConverter';
34 import { getIsPropertyAllDay, getPropertyTzid } from '../vcalHelper';
35 import { EVENT_INVITATION_ERROR_TYPE, EventInvitationError, INVITATION_ERROR_TYPE } from './EventInvitationError';
36 import { IMPORT_EVENT_ERROR_TYPE, ImportEventError } from './ImportEventError';
37 import { getSupportedAlarms } from './valarm';
38 import { getSupportedStringValue } from './vcal';
40 export const getDtendPropertyFromDuration = (
41 dtstart: VcalDateOrDateTimeProperty,
42 duration: VcalDurationValue | number
44 const startDateUTC = propertyToUTCDate(dtstart);
45 const durationInMs = typeof duration === 'number' ? duration : durationToMilliseconds(duration);
46 const timestamp = +startDateUTC + durationInMs;
47 const end = fromUTCDate(fromUnixTime(timestamp / 1000));
49 if (getIsPropertyAllDay(dtstart)) {
50 // The all-day event lasts one day, we don't need DTEND in this case
51 if (durationInMs <= DAY) {
55 const shouldAddDay = !!(durationInMs <= 0 || end.hours || end.minutes || end.seconds);
56 const finalEnd = shouldAddDay
57 ? fromUTCDate(addDays(propertyToUTCDate({ value: { ...end, isUTC: true } }), 1))
60 return dateToProperty(finalEnd);
63 if (durationInMs <= 0) {
64 // The part-day event has zero duration, we don't need DTEND in this case
68 const tzid = getPropertyTzid(dtstart);
70 return getDateTimeProperty(convertUTCDateTimeToZone(end, tzid!), tzid!);
73 export const getSupportedDtstamp = (dtstamp: IcalJSDateOrDateTimeProperty | undefined, timestamp: number) => {
74 // as per RFC, the DTSTAMP value MUST be specified in the UTC time format. But that's not what we always receive from external providers
75 const value = dtstamp?.value;
76 const tzid = dtstamp?.parameters?.tzid;
79 return dateTimeToProperty(fromUTCDate(new Date(timestamp)), true);
83 const supportedTzid = getSupportedTimezone(tzid);
85 // generate a new DTSTAMP
86 return dateTimeToProperty(fromUTCDate(new Date(timestamp)), true);
88 // we try to guess what the external provider meant
89 const guessedProperty = {
94 hours: (value as VcalDateTimeValue)?.hours || 0,
95 minutes: (value as VcalDateTimeValue)?.minutes || 0,
96 seconds: (value as VcalDateTimeValue)?.seconds || 0,
97 isUTC: (value as VcalDateTimeValue)?.isUTC === true,
104 return dateTimeToProperty(fromUTCDate(propertyToUTCDate(guessedProperty)), true);
107 return dateTimeToProperty(fromUTCDate(propertyToUTCDate(dtstamp as VcalDateOrDateTimeProperty)), true);
110 export const withSupportedDtstamp = <T>(
111 properties: RequireOnly<VcalVeventComponent, 'uid' | 'component' | 'dtstart'> & T,
113 ): VcalVeventComponent & T => {
116 dtstamp: getSupportedDtstamp(properties.dtstamp, timestamp),
120 export const getSupportedDateOrDateTimeProperty = ({
122 componentIdentifiers,
129 property: VcalDateOrDateTimeProperty | VcalFloatingDateTimeProperty;
130 componentIdentifiers: EventComponentIdentifiers;
131 hasXWrTimezone: boolean;
132 calendarTzid?: string;
133 isRecurring?: boolean;
134 method?: ICAL_METHOD;
138 if (getIsPropertyAllDay(property)) {
139 return dateToProperty(property.value);
142 const partDayProperty = property;
144 // account for non-RFC-compliant Google Calendar exports
145 // namely localize Zulu date-times for non-recurring events with x-wr-timezone if present and accepted by us
146 if (partDayProperty.value.isUTC && !isRecurring && hasXWrTimezone && calendarTzid) {
147 const localizedDateTime = convertUTCDateTimeToZone(partDayProperty.value, calendarTzid);
148 return getDateTimeProperty(localizedDateTime, calendarTzid);
150 const partDayPropertyTzid = getPropertyTzid(partDayProperty);
152 // A floating date-time property
153 if (!partDayPropertyTzid) {
154 if (!hasXWrTimezone) {
156 return getDateTimeProperty(partDayProperty.value, guessTzid);
159 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_UNSUPPORTED, {
160 componentIdentifiers,
161 extendedType: EVENT_INVITATION_ERROR_TYPE.UNEXPECTED_FLOATING_TIME,
164 // we should never reach here as guessTzid should be always defined for import
165 throw new ImportEventError({
166 errorType: IMPORT_EVENT_ERROR_TYPE.UNEXPECTED_FLOATING_TIME,
167 componentIdentifiers,
170 if (hasXWrTimezone && !calendarTzid) {
172 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_UNSUPPORTED, {
173 componentIdentifiers,
174 extendedType: EVENT_INVITATION_ERROR_TYPE.X_WR_TIMEZONE_UNSUPPORTED,
177 throw new ImportEventError({
178 errorType: IMPORT_EVENT_ERROR_TYPE.X_WR_TIMEZONE_UNSUPPORTED,
179 componentIdentifiers,
182 return getDateTimeProperty(partDayProperty.value, calendarTzid);
185 const supportedTzid = getSupportedTimezone(partDayPropertyTzid);
187 if (!supportedTzid) {
189 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_UNSUPPORTED, {
190 componentIdentifiers,
191 extendedType: EVENT_INVITATION_ERROR_TYPE.TZID_UNSUPPORTED,
194 throw new ImportEventError({
195 errorType: IMPORT_EVENT_ERROR_TYPE.TZID_UNSUPPORTED,
196 componentIdentifiers,
200 return getDateTimeProperty(partDayProperty.value, supportedTzid);
203 export const getLinkedDateTimeProperty = ({
205 componentIdentifiers,
210 property: VcalDateOrDateTimeProperty;
211 componentIdentifiers: EventComponentIdentifiers;
212 linkedIsAllDay: boolean;
215 }): VcalDateOrDateTimeProperty => {
216 if (linkedIsAllDay) {
217 return dateToProperty(property.value);
219 if (getIsPropertyAllDay(property)) {
221 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_INVALID, {
222 componentIdentifiers,
223 extendedType: EVENT_INVITATION_ERROR_TYPE.ALLDAY_INCONSISTENCY,
226 throw new ImportEventError({
227 errorType: IMPORT_EVENT_ERROR_TYPE.ALLDAY_INCONSISTENCY,
228 componentIdentifiers,
231 const supportedTzid = getPropertyTzid(property);
232 if (!supportedTzid || !linkedTzid) {
234 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_UNSUPPORTED, {
235 componentIdentifiers,
236 extendedType: EVENT_INVITATION_ERROR_TYPE.UNEXPECTED_FLOATING_TIME,
239 // should never be reached
240 throw new ImportEventError({
241 errorType: IMPORT_EVENT_ERROR_TYPE.UNEXPECTED_FLOATING_TIME,
242 componentIdentifiers,
245 if (linkedTzid !== supportedTzid) {
246 // the linked date-time property should have the same tzid as dtstart
247 return getDateTimePropertyInDifferentTimezone(property, linkedTzid, linkedIsAllDay);
249 return getDateTimeProperty(property.value, linkedTzid);
252 export const getSupportedSequenceValue = (sequence = 0) => {
254 * According to the RFC (https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.7.4), the sequence property can
255 * have INTEGER values, and the valid range for an integer is that of a 32-byte integer: -2147483648 to 2147483647,
256 * cf. https://www.rfc-editor.org/rfc/rfc5545#section-3.3.8
258 * Our BE does not support negative values, and we should not save anything bigger than 2147483687. We transform
259 * negative values into 0 and take the modulo of bigger ones.
264 if (sequence >= MAX_ICAL_SEQUENCE) {
265 return sequence % MAX_ICAL_SEQUENCE;
270 export const withSupportedSequence = (vevent: VcalVeventComponent) => {
271 const supportedSequence = getSupportedSequenceValue(vevent.sequence?.value);
275 sequence: { value: supportedSequence },
280 * Perform ICS surgery on a VEVENT component
282 export const getSupportedEvent = ({
283 method = ICAL_METHOD.PUBLISH,
288 componentIdentifiers,
290 generatedHashUid = false,
293 method?: ICAL_METHOD;
294 vcalVeventComponent: VcalVeventComponent;
295 hasXWrTimezone: boolean;
296 calendarTzid?: string;
298 componentIdentifiers: EventComponentIdentifiers;
299 isEventInvitation?: boolean;
300 generatedHashUid?: boolean;
301 canImportEventColor?: boolean;
302 }): VcalVeventComponent => {
303 const isPublish = method === ICAL_METHOD.PUBLISH;
304 const isInvitation = isEventInvitation && !isPublish;
320 'recurrence-id': recurrenceId,
324 'x-pm-session-key': sharedSessionKey,
325 'x-pm-shared-event-id': sharedEventID,
326 'x-pm-proton-reply': protonReply,
327 'x-yahoo-yid': xYahooID,
328 'x-yahoo-user-status': xYahooUserStatus,
329 'x-pm-conference-id': xConferenceId,
330 'x-pm-conference-url': xConferenceUrl,
332 } = vcalVeventComponent;
334 const [trimmedSummaryValue, trimmedDescriptionValue, trimmedLocationValue] = [
338 ].map(getSupportedStringValue);
339 const isRecurring = !!rrule || !!recurrenceId;
341 const validated: VcalVeventComponent = {
343 uid: { value: getSupportedUID(uid.value) },
344 dtstamp: { ...dtstamp },
345 dtstart: { ...dtstart },
346 sequence: { value: getSupportedSequenceValue(sequence?.value) },
348 let ignoreRrule = false;
350 if (trimmedSummaryValue) {
351 validated.summary = {
353 value: truncate(trimmedSummaryValue, MAX_CHARS_API.TITLE),
356 if (trimmedDescriptionValue) {
357 validated.description = {
359 value: truncate(trimmedDescriptionValue, MAX_CHARS_API.EVENT_DESCRIPTION),
362 if (trimmedLocationValue) {
363 validated.location = {
365 value: truncate(trimmedLocationValue, MAX_CHARS_API.LOCATION),
369 validated.dtstart = getSupportedDateOrDateTimeProperty({
371 componentIdentifiers,
375 isInvite: isEventInvitation,
379 const isAllDayStart = getIsPropertyAllDay(validated.dtstart);
380 const startTzid = getPropertyTzid(validated.dtstart);
381 if (!getIsWellFormedDateOrDateTime(validated.dtstart)) {
382 if (isEventInvitation) {
383 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_INVALID, {
384 componentIdentifiers,
385 extendedType: EVENT_INVITATION_ERROR_TYPE.DTSTART_MALFORMED,
388 throw new ImportEventError({
389 errorType: IMPORT_EVENT_ERROR_TYPE.DTSTART_MALFORMED,
390 componentIdentifiers,
393 if (getIsDateOutOfBounds(validated.dtstart)) {
394 if (isEventInvitation) {
395 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_UNSUPPORTED, {
396 componentIdentifiers,
397 extendedType: EVENT_INVITATION_ERROR_TYPE.DTSTART_OUT_OF_BOUNDS,
400 throw new ImportEventError({
401 errorType: IMPORT_EVENT_ERROR_TYPE.DTSTART_OUT_OF_BOUNDS,
402 componentIdentifiers,
406 const supportedDtend = getSupportedDateOrDateTimeProperty({
408 componentIdentifiers,
412 isInvite: isEventInvitation,
415 if (!getIsWellFormedDateOrDateTime(supportedDtend)) {
416 if (isEventInvitation) {
417 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_INVALID, {
418 componentIdentifiers,
419 extendedType: EVENT_INVITATION_ERROR_TYPE.DTEND_MALFORMED,
422 throw new ImportEventError({
423 errorType: IMPORT_EVENT_ERROR_TYPE.DTEND_MALFORMED,
424 componentIdentifiers,
427 const startDateUTC = propertyToUTCDate(validated.dtstart);
428 const endDateUTC = propertyToUTCDate(supportedDtend);
429 // allow a non-RFC-compliant all-day event with DTSTART = DTEND
430 const modifiedEndDateUTC =
431 !getIsPropertyAllDay(dtend) || +startDateUTC === +endDateUTC ? endDateUTC : addDays(endDateUTC, -1);
432 const eventDuration = +modifiedEndDateUTC - +startDateUTC;
434 if (eventDuration > 0) {
435 validated.dtend = supportedDtend;
437 } else if (duration) {
438 const dtendFromDuration = getDtendPropertyFromDuration(validated.dtstart, duration.value);
440 if (dtendFromDuration) {
441 validated.dtend = dtendFromDuration;
445 if (validated.dtend && getIsDateOutOfBounds(validated.dtend)) {
446 if (isEventInvitation) {
447 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_UNSUPPORTED, {
448 componentIdentifiers,
449 extendedType: EVENT_INVITATION_ERROR_TYPE.DTEND_OUT_OF_BOUNDS,
452 throw new ImportEventError({
453 errorType: IMPORT_EVENT_ERROR_TYPE.DTEND_OUT_OF_BOUNDS,
454 componentIdentifiers,
458 const isAllDayEnd = validated.dtend ? getIsPropertyAllDay(validated.dtend) : undefined;
460 if (isAllDayEnd !== undefined && +isAllDayStart ^ +isAllDayEnd) {
461 if (isEventInvitation) {
462 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_INVALID, {
463 componentIdentifiers,
464 extendedType: EVENT_INVITATION_ERROR_TYPE.ALLDAY_INCONSISTENCY,
467 throw new ImportEventError({
468 errorType: IMPORT_EVENT_ERROR_TYPE.ALLDAY_INCONSISTENCY,
469 componentIdentifiers,
475 if (isEventInvitation) {
476 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_INVALID, {
477 componentIdentifiers,
478 extendedType: EVENT_INVITATION_ERROR_TYPE.RRULE_MALFORMED,
481 throw new ImportEventError({
482 errorType: IMPORT_EVENT_ERROR_TYPE.RRULE_MALFORMED,
483 componentIdentifiers,
486 const supportedExdate = exdate.map((property) =>
487 getSupportedDateOrDateTimeProperty({
489 componentIdentifiers,
493 isInvite: isEventInvitation,
497 validated.exdate = supportedExdate.map((property) =>
498 getLinkedDateTimeProperty({
500 componentIdentifiers,
501 linkedIsAllDay: isAllDayStart,
502 linkedTzid: startTzid,
503 isInvite: isEventInvitation,
507 // Do not keep recurrence ids when we generated a hash UID, as the RECURRENCE-ID is meaningless then
508 if (recurrenceId && !generatedHashUid) {
510 if (method === ICAL_METHOD.REPLY) {
511 // the external provider forgot to remove the RRULE
514 if (isEventInvitation) {
515 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_UNSUPPORTED, {
516 componentIdentifiers,
517 extendedType: EVENT_INVITATION_ERROR_TYPE.SINGLE_EDIT_UNSUPPORTED,
520 throw new ImportEventError({
521 errorType: IMPORT_EVENT_ERROR_TYPE.SINGLE_EDIT_UNSUPPORTED,
522 componentIdentifiers,
526 // RECURRENCE-ID cannot be linked with DTSTART of the parent event at this point since we do not have access to it
527 validated['recurrence-id'] = getSupportedDateOrDateTimeProperty({
528 property: recurrenceId,
529 componentIdentifiers,
533 isInvite: isEventInvitation,
538 if (rrule && !ignoreRrule) {
539 const supportedRrule = getSupportedRrule({ ...validated, rrule }, isInvitation, guessTzid);
540 if (!supportedRrule) {
541 if (isEventInvitation) {
542 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_UNSUPPORTED, {
543 componentIdentifiers,
544 extendedType: EVENT_INVITATION_ERROR_TYPE.RRULE_UNSUPPORTED,
547 throw new ImportEventError({
548 errorType: IMPORT_EVENT_ERROR_TYPE.RRULE_UNSUPPORTED,
549 componentIdentifiers,
552 validated.rrule = supportedRrule;
553 if (!getHasConsistentRrule(validated)) {
554 if (isEventInvitation) {
555 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_INVALID, {
556 componentIdentifiers,
557 extendedType: EVENT_INVITATION_ERROR_TYPE.RRULE_MALFORMED,
560 throw new ImportEventError({
561 errorType: IMPORT_EVENT_ERROR_TYPE.RRULE_MALFORMED,
562 componentIdentifiers,
565 if (!getHasOccurrences(validated)) {
566 if (isEventInvitation) {
567 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_INVALID, {
568 componentIdentifiers,
569 extendedType: EVENT_INVITATION_ERROR_TYPE.NO_OCCURRENCES,
572 throw new ImportEventError({
573 errorType: IMPORT_EVENT_ERROR_TYPE.NO_OCCURRENCES,
574 componentIdentifiers,
579 // import-specific surgery
581 if (!isEventInvitation && isPublish) {
582 const alarms = components?.filter(({ component }) => component === 'valarm') || [];
583 const supportedAlarms = getSupportedAlarms(alarms, dtstart);
584 const dedupedAlarms = dedupeAlarmsWithNormalizedTriggers(supportedAlarms);
586 if (dedupedAlarms.length) {
587 validated.components = dedupedAlarms;
591 if (canImportEventColor && color) {
592 const closestColor = getClosestProtonColor(color.value);
594 // If closest color is undefined, it means that the color format was invalid. In that case, we ignore the color.
596 validated.color = { value: closestColor };
601 // Zoom integration specific surgery
603 // Needed to interpret non RFC-compliant Yahoo REPLY ics's
604 validated['x-pm-conference-id'] = { ...xConferenceId };
606 if (xConferenceUrl) {
607 // Needed to interpret non RFC-compliant Yahoo REPLY ics's
608 validated['x-pm-conference-url'] = { ...xConferenceUrl };
611 // invite-specific surgery
613 if (sharedSessionKey) {
614 validated['x-pm-session-key'] = { ...sharedSessionKey };
617 validated['x-pm-shared-event-id'] = { ...sharedEventID };
620 validated['x-pm-proton-reply'] = { ...protonReply };
623 // Needed to interpret non RFC-compliant Yahoo REPLY ics's
624 validated['x-yahoo-yid'] = { ...xYahooID };
626 if (xYahooUserStatus) {
627 // Needed to interpret non RFC-compliant Yahoo REPLY ics's
628 validated['x-yahoo-user-status'] = { ...xYahooUserStatus };
631 validated.organizer = getSupportedOrganizer(organizer);
633 // The ORGANIZER field is mandatory in an invitation
634 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_INVALID, {
635 componentIdentifiers,
636 extendedType: EVENT_INVITATION_ERROR_TYPE.MISSING_ORGANIZER,
641 const attendeeEmails = attendee.map((att) => getAttendeeEmail(att));
642 // Some attendees might be malformed, meaning that we receive an empty string as email
643 // We want it to be possible to still add the event in such cases.
644 // To do so, we start by cleaning the emails array, so that we get all correct emails.
645 // Then we can search for duplicate emails, which will lead to an invitation unsupported error
646 const cleanedAttendeeEmails = attendeeEmails.filter((attendee) => attendee !== '');
647 if (unique(cleanedAttendeeEmails).length !== cleanedAttendeeEmails.length) {
648 // Do not accept invitations with repeated emails as they will cause problems.
649 // Usually external providers don't allow this to happen
650 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_UNSUPPORTED, {
651 componentIdentifiers,
652 extendedType: EVENT_INVITATION_ERROR_TYPE.DUPLICATE_ATTENDEES,
655 // Do not add malformed attendee
656 const supportedAttendee = attendee.reduce<RequireSome<VcalAttendeeProperty, 'parameters'>[]>(
657 (acc, vcalAttendee) => {
658 const attendeeEmail = getAttendeeEmail(vcalAttendee);
659 if (validateEmailAddress(attendeeEmail)) {
660 acc.push(getSupportedAttendee(vcalAttendee));
666 validated.attendee = supportedAttendee;
672 if (e instanceof ImportEventError || e instanceof EventInvitationError) {
675 if (isEventInvitation) {
676 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_UNSUPPORTED, {
677 componentIdentifiers,
678 extendedType: EVENT_INVITATION_ERROR_TYPE.EXTERNAL_ERROR,
682 throw new ImportEventError({
683 errorType: IMPORT_EVENT_ERROR_TYPE.VALIDATION_ERROR,
684 componentIdentifiers,