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 truncate from '@proton/utils/truncate';
6 import unique from '@proton/utils/unique';
8 import type { RequireOnly } from '../../../lib/interfaces';
9 import { DAY } from '../../constants';
10 import { convertUTCDateTimeToZone, fromUTCDate, getSupportedTimezone } from '../../date/timezone';
12 IcalJSDateOrDateTimeProperty,
13 VcalDateOrDateTimeProperty,
16 VcalFloatingDateTimeProperty,
18 } from '../../interfaces/calendar';
19 import { dedupeAlarmsWithNormalizedTriggers } from '../alarms';
20 import { getAttendeeEmail, getSupportedAttendee, getSupportedOrganizer } from '../attendees';
21 import { ICAL_METHOD, MAX_CHARS_API, MAX_ICAL_SEQUENCE } from '../constants';
22 import { getIsDateOutOfBounds, getIsWellFormedDateOrDateTime, getSupportedUID } from '../helper';
23 import { getHasConsistentRrule, getHasOccurrences, getSupportedRrule } from '../recurrence/rrule';
24 import { durationToMilliseconds } from '../vcal';
29 getDateTimePropertyInDifferentTimezone,
31 } from '../vcalConverter';
32 import { getIsPropertyAllDay, getPropertyTzid } from '../vcalHelper';
33 import { EVENT_INVITATION_ERROR_TYPE, EventInvitationError, INVITATION_ERROR_TYPE } from './EventInvitationError';
34 import { IMPORT_EVENT_ERROR_TYPE, ImportEventError } from './ImportEventError';
35 import { getSupportedAlarms } from './valarm';
36 import { getSupportedStringValue } from './vcal';
38 export const getDtendPropertyFromDuration = (
39 dtstart: VcalDateOrDateTimeProperty,
40 duration: VcalDurationValue | number
42 const startDateUTC = propertyToUTCDate(dtstart);
43 const durationInMs = typeof duration === 'number' ? duration : durationToMilliseconds(duration);
44 const timestamp = +startDateUTC + durationInMs;
45 const end = fromUTCDate(fromUnixTime(timestamp / 1000));
47 if (getIsPropertyAllDay(dtstart)) {
48 // The all-day event lasts one day, we don't need DTEND in this case
49 if (durationInMs <= DAY) {
53 const shouldAddDay = !!(durationInMs <= 0 || end.hours || end.minutes || end.seconds);
54 const finalEnd = shouldAddDay
55 ? fromUTCDate(addDays(propertyToUTCDate({ value: { ...end, isUTC: true } }), 1))
58 return dateToProperty(finalEnd);
61 if (durationInMs <= 0) {
62 // The part-day event has zero duration, we don't need DTEND in this case
66 const tzid = getPropertyTzid(dtstart);
68 return getDateTimeProperty(convertUTCDateTimeToZone(end, tzid!), tzid!);
71 export const getSupportedDtstamp = (dtstamp: IcalJSDateOrDateTimeProperty | undefined, timestamp: number) => {
72 // 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
73 const value = dtstamp?.value;
74 const tzid = dtstamp?.parameters?.tzid;
77 return dateTimeToProperty(fromUTCDate(new Date(timestamp)), true);
81 const supportedTzid = getSupportedTimezone(tzid);
83 // generate a new DTSTAMP
84 return dateTimeToProperty(fromUTCDate(new Date(timestamp)), true);
86 // we try to guess what the external provider meant
87 const guessedProperty = {
92 hours: (value as VcalDateTimeValue)?.hours || 0,
93 minutes: (value as VcalDateTimeValue)?.minutes || 0,
94 seconds: (value as VcalDateTimeValue)?.seconds || 0,
95 isUTC: (value as VcalDateTimeValue)?.isUTC === true,
102 return dateTimeToProperty(fromUTCDate(propertyToUTCDate(guessedProperty)), true);
105 return dateTimeToProperty(fromUTCDate(propertyToUTCDate(dtstamp as VcalDateOrDateTimeProperty)), true);
108 export const withSupportedDtstamp = <T>(
109 properties: RequireOnly<VcalVeventComponent, 'uid' | 'component' | 'dtstart'> & T,
111 ): VcalVeventComponent & T => {
114 dtstamp: getSupportedDtstamp(properties.dtstamp, timestamp),
118 export const getSupportedDateOrDateTimeProperty = ({
120 componentIdentifiers,
127 property: VcalDateOrDateTimeProperty | VcalFloatingDateTimeProperty;
128 componentIdentifiers: EventComponentIdentifiers;
129 hasXWrTimezone: boolean;
130 calendarTzid?: string;
131 isRecurring?: boolean;
132 method?: ICAL_METHOD;
136 if (getIsPropertyAllDay(property)) {
137 return dateToProperty(property.value);
140 const partDayProperty = property;
142 // account for non-RFC-compliant Google Calendar exports
143 // namely localize Zulu date-times for non-recurring events with x-wr-timezone if present and accepted by us
144 if (partDayProperty.value.isUTC && !isRecurring && hasXWrTimezone && calendarTzid) {
145 const localizedDateTime = convertUTCDateTimeToZone(partDayProperty.value, calendarTzid);
146 return getDateTimeProperty(localizedDateTime, calendarTzid);
148 const partDayPropertyTzid = getPropertyTzid(partDayProperty);
150 // A floating date-time property
151 if (!partDayPropertyTzid) {
152 if (!hasXWrTimezone) {
154 return getDateTimeProperty(partDayProperty.value, guessTzid);
157 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_UNSUPPORTED, {
158 componentIdentifiers,
159 extendedType: EVENT_INVITATION_ERROR_TYPE.UNEXPECTED_FLOATING_TIME,
162 // we should never reach here as guessTzid should be always defined for import
163 throw new ImportEventError({
164 errorType: IMPORT_EVENT_ERROR_TYPE.UNEXPECTED_FLOATING_TIME,
165 componentIdentifiers,
168 if (hasXWrTimezone && !calendarTzid) {
170 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_UNSUPPORTED, {
171 componentIdentifiers,
172 extendedType: EVENT_INVITATION_ERROR_TYPE.X_WR_TIMEZONE_UNSUPPORTED,
175 throw new ImportEventError({
176 errorType: IMPORT_EVENT_ERROR_TYPE.X_WR_TIMEZONE_UNSUPPORTED,
177 componentIdentifiers,
180 return getDateTimeProperty(partDayProperty.value, calendarTzid);
183 const supportedTzid = getSupportedTimezone(partDayPropertyTzid);
185 if (!supportedTzid) {
187 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_UNSUPPORTED, {
188 componentIdentifiers,
189 extendedType: EVENT_INVITATION_ERROR_TYPE.TZID_UNSUPPORTED,
192 throw new ImportEventError({
193 errorType: IMPORT_EVENT_ERROR_TYPE.TZID_UNSUPPORTED,
194 componentIdentifiers,
198 return getDateTimeProperty(partDayProperty.value, supportedTzid);
201 export const getLinkedDateTimeProperty = ({
203 componentIdentifiers,
208 property: VcalDateOrDateTimeProperty;
209 componentIdentifiers: EventComponentIdentifiers;
210 linkedIsAllDay: boolean;
213 }): VcalDateOrDateTimeProperty => {
214 if (linkedIsAllDay) {
215 return dateToProperty(property.value);
217 if (getIsPropertyAllDay(property)) {
219 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_INVALID, {
220 componentIdentifiers,
221 extendedType: EVENT_INVITATION_ERROR_TYPE.ALLDAY_INCONSISTENCY,
224 throw new ImportEventError({
225 errorType: IMPORT_EVENT_ERROR_TYPE.ALLDAY_INCONSISTENCY,
226 componentIdentifiers,
229 const supportedTzid = getPropertyTzid(property);
230 if (!supportedTzid || !linkedTzid) {
232 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_UNSUPPORTED, {
233 componentIdentifiers,
234 extendedType: EVENT_INVITATION_ERROR_TYPE.UNEXPECTED_FLOATING_TIME,
237 // should never be reached
238 throw new ImportEventError({
239 errorType: IMPORT_EVENT_ERROR_TYPE.UNEXPECTED_FLOATING_TIME,
240 componentIdentifiers,
243 if (linkedTzid !== supportedTzid) {
244 // the linked date-time property should have the same tzid as dtstart
245 return getDateTimePropertyInDifferentTimezone(property, linkedTzid, linkedIsAllDay);
247 return getDateTimeProperty(property.value, linkedTzid);
250 export const getSupportedSequenceValue = (sequence = 0) => {
252 * According to the RFC (https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.7.4), the sequence property can
253 * have INTEGER values, and the valid range for an integer is that of a 32-byte integer: -2147483648 to 2147483647,
254 * cf. https://www.rfc-editor.org/rfc/rfc5545#section-3.3.8
256 * Our BE does not support negative values, and we should not save anything bigger than 2147483687. We transform
257 * negative values into 0 and take the modulo of bigger ones.
262 if (sequence >= MAX_ICAL_SEQUENCE) {
263 return sequence % MAX_ICAL_SEQUENCE;
268 export const withSupportedSequence = (vevent: VcalVeventComponent) => {
269 const supportedSequence = getSupportedSequenceValue(vevent.sequence?.value);
273 sequence: { value: supportedSequence },
278 * Perform ICS surgery on a VEVENT component
280 export const getSupportedEvent = ({
281 method = ICAL_METHOD.PUBLISH,
286 componentIdentifiers,
288 generatedHashUid = false,
291 method?: ICAL_METHOD;
292 vcalVeventComponent: VcalVeventComponent;
293 hasXWrTimezone: boolean;
294 calendarTzid?: string;
296 componentIdentifiers: EventComponentIdentifiers;
297 isEventInvitation?: boolean;
298 generatedHashUid?: boolean;
299 canImportEventColor?: boolean;
300 }): VcalVeventComponent => {
301 const isPublish = method === ICAL_METHOD.PUBLISH;
302 const isInvitation = isEventInvitation && !isPublish;
318 'recurrence-id': recurrenceId,
322 'x-pm-session-key': sharedSessionKey,
323 'x-pm-shared-event-id': sharedEventID,
324 'x-pm-proton-reply': protonReply,
325 'x-yahoo-yid': xYahooID,
326 'x-yahoo-user-status': xYahooUserStatus,
328 } = vcalVeventComponent;
330 const [trimmedSummaryValue, trimmedDescriptionValue, trimmedLocationValue] = [
334 ].map(getSupportedStringValue);
335 const isRecurring = !!rrule || !!recurrenceId;
337 const validated: VcalVeventComponent = {
339 uid: { value: getSupportedUID(uid.value) },
340 dtstamp: { ...dtstamp },
341 dtstart: { ...dtstart },
342 sequence: { value: getSupportedSequenceValue(sequence?.value) },
344 let ignoreRrule = false;
346 if (trimmedSummaryValue) {
347 validated.summary = {
349 value: truncate(trimmedSummaryValue, MAX_CHARS_API.TITLE),
352 if (trimmedDescriptionValue) {
353 validated.description = {
355 value: truncate(trimmedDescriptionValue, MAX_CHARS_API.EVENT_DESCRIPTION),
358 if (trimmedLocationValue) {
359 validated.location = {
361 value: truncate(trimmedLocationValue, MAX_CHARS_API.LOCATION),
365 validated.dtstart = getSupportedDateOrDateTimeProperty({
367 componentIdentifiers,
371 isInvite: isEventInvitation,
375 const isAllDayStart = getIsPropertyAllDay(validated.dtstart);
376 const startTzid = getPropertyTzid(validated.dtstart);
377 if (!getIsWellFormedDateOrDateTime(validated.dtstart)) {
378 if (isEventInvitation) {
379 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_INVALID, {
380 componentIdentifiers,
381 extendedType: EVENT_INVITATION_ERROR_TYPE.DTSTART_MALFORMED,
384 throw new ImportEventError({
385 errorType: IMPORT_EVENT_ERROR_TYPE.DTSTART_MALFORMED,
386 componentIdentifiers,
389 if (getIsDateOutOfBounds(validated.dtstart)) {
390 if (isEventInvitation) {
391 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_UNSUPPORTED, {
392 componentIdentifiers,
393 extendedType: EVENT_INVITATION_ERROR_TYPE.DTSTART_OUT_OF_BOUNDS,
396 throw new ImportEventError({
397 errorType: IMPORT_EVENT_ERROR_TYPE.DTSTART_OUT_OF_BOUNDS,
398 componentIdentifiers,
402 const supportedDtend = getSupportedDateOrDateTimeProperty({
404 componentIdentifiers,
408 isInvite: isEventInvitation,
411 if (!getIsWellFormedDateOrDateTime(supportedDtend)) {
412 if (isEventInvitation) {
413 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_INVALID, {
414 componentIdentifiers,
415 extendedType: EVENT_INVITATION_ERROR_TYPE.DTEND_MALFORMED,
418 throw new ImportEventError({
419 errorType: IMPORT_EVENT_ERROR_TYPE.DTEND_MALFORMED,
420 componentIdentifiers,
423 const startDateUTC = propertyToUTCDate(validated.dtstart);
424 const endDateUTC = propertyToUTCDate(supportedDtend);
425 // allow a non-RFC-compliant all-day event with DTSTART = DTEND
426 const modifiedEndDateUTC =
427 !getIsPropertyAllDay(dtend) || +startDateUTC === +endDateUTC ? endDateUTC : addDays(endDateUTC, -1);
428 const eventDuration = +modifiedEndDateUTC - +startDateUTC;
430 if (eventDuration > 0) {
431 validated.dtend = supportedDtend;
433 } else if (duration) {
434 const dtendFromDuration = getDtendPropertyFromDuration(validated.dtstart, duration.value);
436 if (dtendFromDuration) {
437 validated.dtend = dtendFromDuration;
441 if (validated.dtend && getIsDateOutOfBounds(validated.dtend)) {
442 if (isEventInvitation) {
443 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_UNSUPPORTED, {
444 componentIdentifiers,
445 extendedType: EVENT_INVITATION_ERROR_TYPE.DTEND_OUT_OF_BOUNDS,
448 throw new ImportEventError({
449 errorType: IMPORT_EVENT_ERROR_TYPE.DTEND_OUT_OF_BOUNDS,
450 componentIdentifiers,
454 const isAllDayEnd = validated.dtend ? getIsPropertyAllDay(validated.dtend) : undefined;
456 if (isAllDayEnd !== undefined && +isAllDayStart ^ +isAllDayEnd) {
457 if (isEventInvitation) {
458 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_INVALID, {
459 componentIdentifiers,
460 extendedType: EVENT_INVITATION_ERROR_TYPE.ALLDAY_INCONSISTENCY,
463 throw new ImportEventError({
464 errorType: IMPORT_EVENT_ERROR_TYPE.ALLDAY_INCONSISTENCY,
465 componentIdentifiers,
471 if (isEventInvitation) {
472 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_INVALID, {
473 componentIdentifiers,
474 extendedType: EVENT_INVITATION_ERROR_TYPE.RRULE_MALFORMED,
477 throw new ImportEventError({
478 errorType: IMPORT_EVENT_ERROR_TYPE.RRULE_MALFORMED,
479 componentIdentifiers,
482 const supportedExdate = exdate.map((property) =>
483 getSupportedDateOrDateTimeProperty({
485 componentIdentifiers,
489 isInvite: isEventInvitation,
493 validated.exdate = supportedExdate.map((property) =>
494 getLinkedDateTimeProperty({
496 componentIdentifiers,
497 linkedIsAllDay: isAllDayStart,
498 linkedTzid: startTzid,
499 isInvite: isEventInvitation,
503 // Do not keep recurrence ids when we generated a hash UID, as the RECURRENCE-ID is meaningless then
504 if (recurrenceId && !generatedHashUid) {
506 if (method === ICAL_METHOD.REPLY) {
507 // the external provider forgot to remove the RRULE
510 if (isEventInvitation) {
511 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_UNSUPPORTED, {
512 componentIdentifiers,
513 extendedType: EVENT_INVITATION_ERROR_TYPE.SINGLE_EDIT_UNSUPPORTED,
516 throw new ImportEventError({
517 errorType: IMPORT_EVENT_ERROR_TYPE.SINGLE_EDIT_UNSUPPORTED,
518 componentIdentifiers,
522 // RECURRENCE-ID cannot be linked with DTSTART of the parent event at this point since we do not have access to it
523 validated['recurrence-id'] = getSupportedDateOrDateTimeProperty({
524 property: recurrenceId,
525 componentIdentifiers,
529 isInvite: isEventInvitation,
534 if (rrule && !ignoreRrule) {
535 const supportedRrule = getSupportedRrule({ ...validated, rrule }, isInvitation, guessTzid);
536 if (!supportedRrule) {
537 if (isEventInvitation) {
538 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_UNSUPPORTED, {
539 componentIdentifiers,
540 extendedType: EVENT_INVITATION_ERROR_TYPE.RRULE_UNSUPPORTED,
543 throw new ImportEventError({
544 errorType: IMPORT_EVENT_ERROR_TYPE.RRULE_UNSUPPORTED,
545 componentIdentifiers,
548 validated.rrule = supportedRrule;
549 if (!getHasConsistentRrule(validated)) {
550 if (isEventInvitation) {
551 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_INVALID, {
552 componentIdentifiers,
553 extendedType: EVENT_INVITATION_ERROR_TYPE.RRULE_MALFORMED,
556 throw new ImportEventError({
557 errorType: IMPORT_EVENT_ERROR_TYPE.RRULE_MALFORMED,
558 componentIdentifiers,
561 if (!getHasOccurrences(validated)) {
562 if (isEventInvitation) {
563 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_INVALID, {
564 componentIdentifiers,
565 extendedType: EVENT_INVITATION_ERROR_TYPE.NO_OCCURRENCES,
568 throw new ImportEventError({
569 errorType: IMPORT_EVENT_ERROR_TYPE.NO_OCCURRENCES,
570 componentIdentifiers,
575 // import-specific surgery
577 if (!isEventInvitation && isPublish) {
578 const alarms = components?.filter(({ component }) => component === 'valarm') || [];
579 const supportedAlarms = getSupportedAlarms(alarms, dtstart);
580 const dedupedAlarms = dedupeAlarmsWithNormalizedTriggers(supportedAlarms);
582 if (dedupedAlarms.length) {
583 validated.components = dedupedAlarms;
587 if (canImportEventColor && color) {
588 const closestColor = getClosestProtonColor(color.value);
590 // If closest color is undefined, it means that the color format was invalid. In that case, we ignore the color.
592 validated.color = { value: closestColor };
597 // invite-specific surgery
599 if (sharedSessionKey) {
600 validated['x-pm-session-key'] = { ...sharedSessionKey };
603 validated['x-pm-shared-event-id'] = { ...sharedEventID };
606 validated['x-pm-proton-reply'] = { ...protonReply };
609 // Needed to interpret non RFC-compliant Yahoo REPLY ics's
610 validated['x-yahoo-yid'] = { ...xYahooID };
612 if (xYahooUserStatus) {
613 // Needed to interpret non RFC-compliant Yahoo REPLY ics's
614 validated['x-yahoo-user-status'] = { ...xYahooUserStatus };
617 validated.organizer = getSupportedOrganizer(organizer);
619 // The ORGANIZER field is mandatory in an invitation
620 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_INVALID, {
621 componentIdentifiers,
622 extendedType: EVENT_INVITATION_ERROR_TYPE.MISSING_ORGANIZER,
627 const attendeeEmails = attendee.map((att) => getAttendeeEmail(att));
628 if (unique(attendeeEmails).length !== attendeeEmails.length) {
629 // Do not accept invitations with repeated emails as they will cause problems.
630 // Usually external providers don't allow this to happen
631 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_UNSUPPORTED, {
632 componentIdentifiers,
633 extendedType: EVENT_INVITATION_ERROR_TYPE.DUPLICATE_ATTENDEES,
636 validated.attendee = attendee.map((vcalAttendee) => getSupportedAttendee(vcalAttendee));
642 if (e instanceof ImportEventError || e instanceof EventInvitationError) {
645 if (isEventInvitation) {
646 throw new EventInvitationError(INVITATION_ERROR_TYPE.INVITATION_UNSUPPORTED, {
647 componentIdentifiers,
648 extendedType: EVENT_INVITATION_ERROR_TYPE.EXTERNAL_ERROR,
652 throw new ImportEventError({
653 errorType: IMPORT_EVENT_ERROR_TYPE.VALIDATION_ERROR,
654 componentIdentifiers,