diff options
author | MargeBot <321-margebot@users.noreply.gitlab.protontech.ch> | 2022-10-28 10:21:09 +0300 |
---|---|---|
committer | Eduardo Conde Pena <econdepe@proton.ch> | 2022-10-31 04:47:39 +0300 |
commit | 794ce2d62809b832179a76439781b3b478ad029c (patch) | |
tree | 8acfe124572b9f8f0c5e99cc01cce156498d4eee | |
parent | 31115406edaadef828f50d1a2f760faa82fb7ad7 (diff) |
Merge branch 'ics-surgery-for-dtstamp' into 'main'proton-calendar@5.0.5.4release/proton-calendar@5.0.5.0
ICS surgery for DTSTAMP
See merge request web/clients!3666
Changelog:
7 files changed, 276 insertions, 23 deletions
diff --git a/applications/mail/src/app/helpers/calendar/invite.test.ts b/applications/mail/src/app/helpers/calendar/invite.test.ts index 60bc3bcde1..84c2ebe71a 100644 --- a/applications/mail/src/app/helpers/calendar/invite.test.ts +++ b/applications/mail/src/app/helpers/calendar/invite.test.ts @@ -326,6 +326,66 @@ END:VCALENDAR`; }); }); + test('should generate a DTSTAMP from the message if no DTSTAMP was present', async () => { + const invitation = `BEGIN:VCALENDAR +CALSCALE:GREGORIAN +VERSION:2.0 +PRODID:-//Apple Inc.//Mac OS X 10.13.6//EN +BEGIN:VEVENT +UID:test-event +DTSTART;TZID=/mozilla.org/20050126_1/Europe/Brussels:20021231T203000 +DTEND;TZID=/mozilla.org/20050126_1/Europe/Brussels:20030101T003000 +LOCATION:1CP Conference Room 4350 +ATTENDEE;CUTYPE=INDIVIDUAL;EMAIL="testme@pm.me";PARTSTAT=NEED + S-ACTION;RSVP=TRUE:mailto:testme@pm.me +ATTENDEE;CN="testKrt";CUTYPE=INDIVIDUAL;EMAIL="aGmailOne@gmail.co + m";PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:aGmailOne@gmail.com +TRANSP:OPAQUE +ORGANIZER;CN="testKrt":mailto:aGmailOne@gmail.com +BEGIN:VALARM +TRIGGER:-PT15H +ACTION:DISPLAY +END:VALARM +BEGIN:VALARM +TRIGGER:-PT1W2D +ACTION:EMAIL +END:VALARM +END:VEVENT +END:VCALENDAR`; + const parsedInvitation = parseVcalendar(invitation) as VcalVcalendar; + const message = { Time: Date.UTC(2022, 9, 10, 10, 0, 0) / 1000 } as Message; + expect( + await getSupportedEventInvitation({ + vcalComponent: parsedInvitation, + message, + icsBinaryString: invitation, + icsFileName: 'test.ics', + primaryTimezone: 'America/Sao_Paulo', + }) + ).toEqual({ + method: 'PUBLISH', + vevent: expect.objectContaining({ + component: 'vevent', + uid: { value: 'sha1-uid-1d92b0aa7fed011b07b53161798dfeb45cf4e186-original-uid-test-event' }, + dtstamp: { + value: { year: 2022, month: 10, day: 10, hours: 10, minutes: 0, seconds: 0, isUTC: true }, + }, + dtstart: { + value: { year: 2002, month: 12, day: 31, hours: 20, minutes: 30, seconds: 0, isUTC: false }, + parameters: { tzid: 'Europe/Brussels' }, + }, + dtend: { + value: { year: 2003, month: 1, day: 1, hours: 0, minutes: 30, seconds: 0, isUTC: false }, + parameters: { tzid: 'Europe/Brussels' }, + }, + }), + originalVcalInvitation: parsedInvitation, + originalUniqueIdentifier: 'test-event', + hasMultipleVevents: false, + fileName: 'test.ics', + }); + }); + test('should not throw without version, untrimmed calscale and duration', async () => { const invitation = `BEGIN:VCALENDAR CALSCALE: Gregorian diff --git a/applications/mail/src/app/helpers/calendar/invite.ts b/applications/mail/src/app/helpers/calendar/invite.ts index e327d94767..dbabb7004d 100644 --- a/applications/mail/src/app/helpers/calendar/invite.ts +++ b/applications/mail/src/app/helpers/calendar/invite.ts @@ -12,13 +12,12 @@ import { cloneEventInvitationErrorWithConfig, } from '@proton/shared/lib/calendar/icsSurgery/EventInvitationError'; import { getSupportedCalscale } from '@proton/shared/lib/calendar/icsSurgery/vcal'; -import { getSupportedEvent } from '@proton/shared/lib/calendar/icsSurgery/vevent'; +import { getSupportedEvent, withSupportedDtstamp } from '@proton/shared/lib/calendar/icsSurgery/vevent'; import { findAttendee, getParticipant } from '@proton/shared/lib/calendar/integration/invite'; import { getOccurrencesBetween } from '@proton/shared/lib/calendar/recurring'; import { parseWithErrors, serialize } from '@proton/shared/lib/calendar/vcal'; import { buildVcalOrganizer, - dateTimeToProperty, getDtendProperty, propertyToLocalDate, propertyToUTCDate, @@ -42,7 +41,7 @@ import { } from '@proton/shared/lib/calendar/vcalHelper'; import { getIsEventCancelled, withDtstamp } from '@proton/shared/lib/calendar/veventHelper'; import { SECOND } from '@proton/shared/lib/constants'; -import { fromUTCDate, getSupportedTimezone } from '@proton/shared/lib/date/timezone'; +import { getSupportedTimezone } from '@proton/shared/lib/date/timezone'; import { getIsAddressActive, getIsAddressDisabled } from '@proton/shared/lib/helpers/address'; import { canonicalizeEmailByGuess, canonicalizeInternalEmail } from '@proton/shared/lib/helpers/email'; import { splitExtension } from '@proton/shared/lib/helpers/file'; @@ -190,20 +189,6 @@ export const withOutsideUIDAndSequence = (vevent: VcalVeventComponent, vcal: Non return result; }; -const withMessageDtstamp = <T>( - properties: VcalVeventComponent & T, - { Time }: MessageWithOptionalBody -): VcalVeventComponent & T => { - if (properties.dtstamp) { - return properties; - } - // use the received time of the mail as dtstamp - return { - ...properties, - dtstamp: dateTimeToProperty(fromUTCDate(new Date(Time * SECOND)), true), - }; -}; - export const getHasMultipleVevents = (vcal?: VcalVcalendar) => { const numberOfVevents = vcal?.components?.filter(unary(getIsEventComponent)).length || 0; return numberOfVevents > 1; @@ -624,7 +609,10 @@ export const getSupportedEventInvitation = async ({ if (!getHasDtStart(vevent)) { throw new EventInvitationError(EVENT_INVITATION_ERROR_TYPE.INVITATION_INVALID, { method: supportedMethod }); } - const completeVevent = withOutsideUIDAndSequence(vevent, vcalComponent); + const completeVevent = withOutsideUIDAndSequence( + withSupportedDtstamp(vevent, message.Time * SECOND), + vcalComponent + ); const hasMultipleVevents = getHasMultipleVevents(vcalComponent); const isImport = supportedMethod === ICAL_METHOD.PUBLISH; // To filter potentially equivalent invitation ics's, we have to generate a reliable @@ -655,7 +643,7 @@ export const getSupportedEventInvitation = async ({ try { const supportedEvent = getSupportedEvent({ method: supportedMethod, - vcalVeventComponent: withMessageDtstamp(completeVevent, message), + vcalVeventComponent: completeVevent, hasXWrTimezone, calendarTzid, guessTzid, diff --git a/packages/shared/lib/calendar/icsSurgery/vevent.ts b/packages/shared/lib/calendar/icsSurgery/vevent.ts index 39e557310c..28e84a2f41 100644 --- a/packages/shared/lib/calendar/icsSurgery/vevent.ts +++ b/packages/shared/lib/calendar/icsSurgery/vevent.ts @@ -3,10 +3,13 @@ import { addDays, fromUnixTime } from 'date-fns'; import truncate from '@proton/utils/truncate'; import unique from '@proton/utils/unique'; +import { RequireOnly } from '../../../lib/interfaces'; import { DAY } from '../../constants'; import { convertUTCDateTimeToZone, fromUTCDate, getSupportedTimezone } from '../../date/timezone'; import { + IcalJSDateOrDateTimeProperty, VcalDateOrDateTimeProperty, + VcalDateTimeValue, VcalDurationValue, VcalFloatingDateTimeProperty, VcalVeventComponent, @@ -18,6 +21,7 @@ import { getIsDateOutOfBounds, getIsWellFormedDateOrDateTime, getSupportedUID } import { getHasConsistentRrule, getHasOccurrences, getSupportedRrule } from '../rrule'; import { durationToMilliseconds } from '../vcal'; import { + dateTimeToProperty, dateToProperty, getDateTimeProperty, getDateTimePropertyInDifferentTimezone, @@ -62,6 +66,53 @@ export const getDtendPropertyFromDuration = ( return getDateTimeProperty(convertUTCDateTimeToZone(end, tzid!), tzid!); }; +export const getSupportedDtstamp = (dtstamp: IcalJSDateOrDateTimeProperty | undefined, timestamp: number) => { + // 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 + const value = dtstamp?.value; + const tzid = dtstamp?.parameters?.tzid; + + if (!value) { + return dateTimeToProperty(fromUTCDate(new Date(timestamp)), true); + } + + if (tzid) { + const supportedTzid = getSupportedTimezone(tzid); + if (!supportedTzid) { + // generate a new DTSTAMP + return dateTimeToProperty(fromUTCDate(new Date(timestamp)), true); + } + // we try to guess what the external provider meant + const guessedProperty = { + value: { + year: value.year, + month: value.month, + day: value.day, + hours: (value as VcalDateTimeValue)?.hours || 0, + minutes: (value as VcalDateTimeValue)?.minutes || 0, + seconds: (value as VcalDateTimeValue)?.seconds || 0, + isUTC: (value as VcalDateTimeValue)?.isUTC === true, + }, + parameters: { + tzid: supportedTzid, + }, + }; + + return dateTimeToProperty(fromUTCDate(propertyToUTCDate(guessedProperty)), true); + } + + return dateTimeToProperty(fromUTCDate(propertyToUTCDate(dtstamp as VcalDateOrDateTimeProperty)), true); +}; + +export const withSupportedDtstamp = <T>( + properties: RequireOnly<VcalVeventComponent, 'uid' | 'component' | 'dtstart'> & T, + timestamp: number +): VcalVeventComponent & T => { + return { + ...properties, + dtstamp: getSupportedDtstamp(properties.dtstamp, timestamp), + }; +}; + export const getSupportedDateOrDateTimeProperty = ({ property, component, diff --git a/packages/shared/lib/calendar/import/import.ts b/packages/shared/lib/calendar/import/import.ts index 47fd5eda03..5b756860a2 100644 --- a/packages/shared/lib/calendar/import/import.ts +++ b/packages/shared/lib/calendar/import/import.ts @@ -1,5 +1,6 @@ import { c } from 'ttag'; +import { serverTime } from '@proton/crypto'; import isTruthy from '@proton/utils/isTruthy'; import truncate from '@proton/utils/truncate'; import unique from '@proton/utils/unique'; @@ -23,7 +24,7 @@ import getComponentFromCalendarEvent from '../getComponentFromCalendarEvent'; import { generateVeventHashUID, getOriginalUID } from '../helper'; import { IMPORT_EVENT_ERROR_TYPE, ImportEventError } from '../icsSurgery/ImportEventError'; import { getSupportedCalscale } from '../icsSurgery/vcal'; -import { getLinkedDateTimeProperty, getSupportedEvent } from '../icsSurgery/vevent'; +import { getLinkedDateTimeProperty, getSupportedEvent, withSupportedDtstamp } from '../icsSurgery/vevent'; import { parseWithErrors, serialize } from '../vcal'; import { getHasDtStart, @@ -38,7 +39,6 @@ import { getIsTodoComponent, getPropertyTzid, } from '../vcalHelper'; -import { withDtstamp } from '../veventHelper'; import { ImportFileError } from './ImportFileError'; const getParsedComponentHasError = (component: VcalCalendarComponentOrError): component is { error: Error } => { @@ -167,7 +167,7 @@ export const extractSupportedEvent = async ({ if (!getHasDtStart(vcalComponent)) { throw new ImportEventError(IMPORT_EVENT_ERROR_TYPE.DTSTART_MISSING, 'vevent', componentId); } - const validVevent = withDtstamp(vcalComponent); + const validVevent = withSupportedDtstamp(vcalComponent, +serverTime()); const generateHashUid = !validVevent.uid?.value || isInvitation; if (generateHashUid) { validVevent.uid = { value: await generateVeventHashUID(serialize(vcalComponent), vcalComponent?.uid?.value) }; diff --git a/packages/shared/lib/interfaces/calendar/VcalModel.ts b/packages/shared/lib/interfaces/calendar/VcalModel.ts index b7d636fcb1..eaec844f7d 100644 --- a/packages/shared/lib/interfaces/calendar/VcalModel.ts +++ b/packages/shared/lib/interfaces/calendar/VcalModel.ts @@ -57,6 +57,14 @@ export interface VcalFloatingDateTimeProperty { value: VcalDateTimeValue; } +export interface IcalJSDateOrDateTimeProperty { + parameters?: { + type?: 'date' | 'date-time'; + tzid?: string; + }; + value: VcalDateValue | VcalDateTimeValue; +} + export type VcalDateOrDateTimeProperty = VcalDateProperty | VcalDateTimeProperty; export type VcalRruleFreqValue = 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY' | undefined | string; diff --git a/packages/shared/test/calendar/icsSurgery/vevent.spec.ts b/packages/shared/test/calendar/icsSurgery/vevent.spec.ts index 7ce17e1068..083329c0cf 100644 --- a/packages/shared/test/calendar/icsSurgery/vevent.spec.ts +++ b/packages/shared/test/calendar/icsSurgery/vevent.spec.ts @@ -1,4 +1,100 @@ -import { getDtendPropertyFromDuration } from '../../../lib/calendar/icsSurgery/vevent'; +import { getDtendPropertyFromDuration, getSupportedDtstamp } from '../../../lib/calendar/icsSurgery/vevent'; + +describe('getSupportedDtstamp()', () => { + it('leaves untouched a proper DTSTAMP', () => { + expect( + getSupportedDtstamp( + { + value: { year: 2020, month: 1, day: 31, hours: 15, minutes: 11, seconds: 11, isUTC: true }, + }, + 1666017619812 + ) + ).toEqual({ value: { year: 2020, month: 1, day: 31, hours: 15, minutes: 11, seconds: 11, isUTC: true } }); + }); + + it('leaves untouched a DTSTAMP with Zulu marker and time zone', () => { + expect( + getSupportedDtstamp( + { + value: { year: 2020, month: 1, day: 31, hours: 15, minutes: 11, seconds: 11, isUTC: true }, + parameters: { tzid: 'Asia/Seoul' }, + }, + 1666017619812 + ) + ).toEqual({ value: { year: 2020, month: 1, day: 31, hours: 15, minutes: 11, seconds: 11, isUTC: true } }); + }); + + it('converts a time-zoned DTSTAMP', () => { + expect( + getSupportedDtstamp( + { + value: { year: 2020, month: 1, day: 31, hours: 15, minutes: 11, seconds: 11, isUTC: false }, + parameters: { tzid: 'America/Montevideo' }, + }, + 1666017619812 + ) + ).toEqual({ value: { year: 2020, month: 1, day: 31, hours: 18, minutes: 11, seconds: 11, isUTC: true } }); + }); + + it('converts a time-zoned DTSTAMP with a TZID that needs conversion', () => { + expect( + getSupportedDtstamp( + { + value: { year: 2020, month: 1, day: 31, hours: 15, minutes: 11, seconds: 11, isUTC: false }, + parameters: { tzid: '/mozilla.org/20050126_1/Asia/Pyongyang' }, + }, + 1666017619812 + ) + ).toEqual({ value: { year: 2020, month: 1, day: 31, hours: 6, minutes: 11, seconds: 11, isUTC: true } }); + }); + + it('converts a floating DTSTAMP', () => { + expect( + getSupportedDtstamp( + { + value: { year: 2020, month: 1, day: 31, hours: 15, minutes: 11, seconds: 11, isUTC: false }, + }, + 1666017619812 + ) + ).toEqual({ value: { year: 2020, month: 1, day: 31, hours: 15, minutes: 11, seconds: 11, isUTC: true } }); + }); + + it('converts an all-day DTSTAMP', () => { + expect( + getSupportedDtstamp( + { + value: { year: 2020, month: 1, day: 31 }, + parameters: { type: 'date' }, + }, + 1666017619812 + ) + ).toEqual({ value: { year: 2020, month: 1, day: 31, hours: 0, minutes: 0, seconds: 0, isUTC: true } }); + }); + + it('converts an all-day DTSTAMP with TZID', () => { + expect( + getSupportedDtstamp( + { + value: { year: 2020, month: 1, day: 31 }, + parameters: { type: 'date', tzid: 'America/Montevideo' }, + }, + 1666017619812 + ) + ).toEqual({ value: { year: 2020, month: 1, day: 31, hours: 3, minutes: 0, seconds: 0, isUTC: true } }); + }); + + it('defaults to the given timestamp if a time zone is present but not supported', () => { + expect( + getSupportedDtstamp( + { + value: { year: 2020, month: 1, day: 31 }, + parameters: { type: 'date', tzid: 'Europe/My_home' }, + }, + 1666017619812 + ) + ).toEqual({ value: { year: 2022, month: 10, day: 17, hours: 14, minutes: 40, seconds: 19, isUTC: true } }); + }); +}); describe('getDtendPropertyFromDuration()', () => { it('returns the appropriate dtend when given a duration and start', () => { diff --git a/packages/shared/test/calendar/import.spec.ts b/packages/shared/test/calendar/import.spec.ts index 823d75a9c2..50b1721625 100644 --- a/packages/shared/test/calendar/import.spec.ts +++ b/packages/shared/test/calendar/import.spec.ts @@ -1007,6 +1007,56 @@ END:VEVENT`; }); }); + it('should fix bad DTSTAMPs', async () => { + const vevent = `BEGIN:VEVENT +DTSTART;TZID=America/New_York:20221012T171500 +DTEND;TZID=America/New_York:20221012T182500 +DTSTAMP;TZID=America/New_York:20221007T151646 +UID:11353R6@voltigeursbourget.com +SEQUENCE:0 +END:VEVENT`; + const event = parse(vevent) as VcalVeventComponent & Required<Pick<VcalVeventComponent, 'dtend'>>; + const supportedEvent = await extractSupportedEvent({ + method: ICAL_METHOD.PUBLISH, + vcalComponent: event, + hasXWrTimezone: false, + guessTzid: 'Europe/Zurich', + }); + expect(supportedEvent).toEqual({ + component: 'vevent', + uid: { value: '11353R6@voltigeursbourget.com' }, + dtstamp: { + value: { year: 2022, month: 10, day: 7, hours: 19, minutes: 16, seconds: 46, isUTC: true }, + }, + dtstart: { + value: { year: 2022, month: 10, day: 12, hours: 17, minutes: 15, seconds: 0, isUTC: false }, + parameters: { tzid: 'America/New_York' }, + }, + dtend: { + value: { year: 2022, month: 10, day: 12, hours: 18, minutes: 25, seconds: 0, isUTC: false }, + parameters: { tzid: 'America/New_York' }, + }, + sequence: { value: 0 }, + }); + }); + + it('should generate DTSTAMP if not present', async () => { + const vevent = `BEGIN:VEVENT +DTSTART;TZID=America/New_York:20221012T171500 +DTEND;TZID=America/New_York:20221012T182500 +UID:11353R6@voltigeursbourget.com +SEQUENCE:0 +END:VEVENT`; + const event = parse(vevent) as VcalVeventComponent & Required<Pick<VcalVeventComponent, 'dtend'>>; + const supportedEvent = await extractSupportedEvent({ + method: ICAL_METHOD.PUBLISH, + vcalComponent: event, + hasXWrTimezone: false, + guessTzid: 'Europe/Zurich', + }); + expect(Object.keys(supportedEvent)).toContain('dtstamp'); + }); + it('should not import alarms for invitations', async () => { const vevent = ` BEGIN:VEVENT |