1 import { CryptoProxy } from '@proton/crypto';
2 import { arrayToHexString, binaryStringToArray } from '@proton/crypto/lib/utils';
3 import groupWith from '@proton/utils/groupWith';
4 import isTruthy from '@proton/utils/isTruthy';
5 import unary from '@proton/utils/unary';
7 import { CONTACT_NAME_MAX_LENGTH } from '../contacts/constants';
8 import { buildMailTo, canonicalizeEmailByGuess, getEmailTo, validateEmailAddress } from '../helpers/email';
9 import { omit } from '../helpers/object';
10 import { normalize, truncatePossiblyQuotedString } from '../helpers/string';
15 VcalOrganizerProperty,
16 VcalPmVeventComponent,
18 } from '../interfaces/calendar';
19 import type { GetCanonicalEmailsMap } from '../interfaces/hooks/GetCanonicalEmailsMap';
20 import type { RequireSome, SimpleMap } from '../interfaces/utils';
21 import { ATTENDEE_STATUS_API, ICAL_ATTENDEE_ROLE, ICAL_ATTENDEE_RSVP, ICAL_ATTENDEE_STATUS } from './constants';
22 import { getAttendeeHasToken, getAttendeePartstat, getAttendeesHaveToken } from './vcalHelper';
24 export const generateAttendeeToken = async (normalizedEmail: string, uid: string) => {
25 const uidEmail = `${uid}${normalizedEmail}`;
26 const byteArray = binaryStringToArray(uidEmail);
27 const hash = await CryptoProxy.computeHash({ algorithm: 'unsafeSHA1', data: byteArray });
28 return arrayToHexString(hash);
31 export const toApiPartstat = (partstat?: string) => {
32 if (partstat === ICAL_ATTENDEE_STATUS.TENTATIVE) {
33 return ATTENDEE_STATUS_API.TENTATIVE;
35 if (partstat === ICAL_ATTENDEE_STATUS.ACCEPTED) {
36 return ATTENDEE_STATUS_API.ACCEPTED;
38 if (partstat === ICAL_ATTENDEE_STATUS.DECLINED) {
39 return ATTENDEE_STATUS_API.DECLINED;
41 return ATTENDEE_STATUS_API.NEEDS_ACTION;
44 export const toIcsPartstat = (partstat?: ATTENDEE_STATUS_API) => {
45 if (partstat === ATTENDEE_STATUS_API.TENTATIVE) {
46 return ICAL_ATTENDEE_STATUS.TENTATIVE;
48 if (partstat === ATTENDEE_STATUS_API.ACCEPTED) {
49 return ICAL_ATTENDEE_STATUS.ACCEPTED;
51 if (partstat === ATTENDEE_STATUS_API.DECLINED) {
52 return ICAL_ATTENDEE_STATUS.DECLINED;
54 return ICAL_ATTENDEE_STATUS.NEEDS_ACTION;
57 export const fromInternalAttendee = ({
58 parameters: { 'x-pm-token': token = '', partstat, ...restParameters } = {},
60 }: VcalAttendeeProperty) => {
71 status: toApiPartstat(partstat),
76 export const toInternalAttendee = (
77 { attendee: attendees = [] }: Pick<VcalVeventComponent, 'attendee'>,
78 clear: Attendee[] = []
79 ): VcalAttendeeProperty[] => {
80 return attendees.map((attendee) => {
81 if (!attendee.parameters) {
84 const token = attendee.parameters['x-pm-token'];
85 const extra = clear.find(({ Token }) => Token === token);
86 if (!token || !extra) {
89 const partstat = toIcsPartstat(extra.Status);
93 ...attendee.parameters,
100 export const getAttendeeEmail = (attendee: VcalAttendeeProperty | VcalOrganizerProperty) => {
101 const { cn, email } = attendee.parameters || {};
102 const emailTo = getEmailTo(attendee.value);
103 if (validateEmailAddress(emailTo)) {
106 if (email && validateEmailAddress(email)) {
109 if (cn && validateEmailAddress(cn)) {
115 export const withPartstat = (attendee: VcalAttendeeProperty, partstat?: ICAL_ATTENDEE_STATUS) => ({
118 ...attendee.parameters,
123 export const modifyAttendeesPartstat = (
124 attendees: VcalAttendeeProperty[],
125 partstatMap: SimpleMap<ICAL_ATTENDEE_STATUS>
127 const emailsToModify = Object.keys(partstatMap);
128 return attendees.map((attendee) => {
129 const email = getAttendeeEmail(attendee);
130 if (!emailsToModify.includes(email)) {
133 return withPartstat(attendee, partstatMap[email]);
137 export const getSupportedOrganizer = (organizer: VcalOrganizerProperty) => {
138 const { parameters: { cn } = {} } = organizer;
139 const emailAddress = getAttendeeEmail(organizer);
140 const supportedOrganizer: RequireSome<VcalAttendeeProperty, 'parameters'> = {
141 value: buildMailTo(emailAddress),
143 cn: truncatePossiblyQuotedString(cn ?? emailAddress, CONTACT_NAME_MAX_LENGTH),
147 return supportedOrganizer;
150 export const getSupportedAttendee = (attendee: VcalAttendeeProperty) => {
151 const { parameters: { cn, role, partstat, rsvp, 'x-pm-token': token } = {} } = attendee;
152 const emailAddress = getAttendeeEmail(attendee);
153 const supportedAttendee: RequireSome<VcalAttendeeProperty, 'parameters'> = {
154 value: buildMailTo(emailAddress),
156 cn: truncatePossiblyQuotedString(cn ?? emailAddress, CONTACT_NAME_MAX_LENGTH),
159 const normalizedUppercasedRole = normalize(role).toUpperCase();
162 normalizedUppercasedRole === ICAL_ATTENDEE_ROLE.REQUIRED ||
163 normalizedUppercasedRole === ICAL_ATTENDEE_ROLE.OPTIONAL
165 supportedAttendee.parameters.role = normalizedUppercasedRole;
168 if (normalize(rsvp) === 'true') {
169 supportedAttendee.parameters.rsvp = 'TRUE';
173 supportedAttendee.parameters.partstat = getAttendeePartstat(attendee);
176 if (token?.length === 40) {
177 supportedAttendee.parameters['x-pm-token'] = token;
180 return supportedAttendee;
183 export const getCanonicalEmails = async (
184 attendees: VcalAttendeeProperty[] = [],
185 getCanonicalEmailsMap: GetCanonicalEmailsMap
187 return Object.values(await getCanonicalEmailsMap(attendees.map(unary(getAttendeeEmail)))).filter(isTruthy);
190 export const withPmAttendees = async (
191 vevent: VcalVeventComponent,
192 getCanonicalEmailsMap: GetCanonicalEmailsMap,
194 ): Promise<VcalPmVeventComponent> => {
195 const { uid, attendee: vcalAttendee } = vevent;
196 if (!vcalAttendee?.length) {
197 return omit(vevent, ['attendee']);
199 const attendeesWithEmail = vcalAttendee.map((attendee) => {
200 const emailAddress = getAttendeeEmail(attendee);
206 const emailsWithoutToken = attendeesWithEmail
207 .filter(({ attendee }) => !attendee.parameters?.['x-pm-token'])
208 .map(({ emailAddress }) => emailAddress);
209 const canonicalEmailMap = await getCanonicalEmailsMap(emailsWithoutToken);
211 const pmAttendees = await Promise.all(
212 attendeesWithEmail.map(async ({ attendee, emailAddress }) => {
213 const supportedAttendee = getSupportedAttendee(attendee);
214 if (getAttendeeHasToken(supportedAttendee)) {
215 return supportedAttendee;
217 const canonicalEmail = canonicalEmailMap[emailAddress];
218 if (!canonicalEmail && !ignoreErrors) {
219 throw new Error('No canonical email provided');
221 // If the participant has an invalid email and we ignore errors, we fall back to the provided email address
222 const token = await generateAttendeeToken(canonicalEmail || emailAddress, uid.value);
224 ...supportedAttendee,
226 ...supportedAttendee.parameters,
234 attendee: pmAttendees,
238 export const getEquivalentAttendees = (attendees?: VcalAttendeeProperty[]) => {
239 if (!attendees?.length) {
242 if (getAttendeesHaveToken(attendees)) {
243 const attendeesWithToken = attendees.map((attendee) => ({
244 token: attendee.parameters['x-pm-token'],
245 email: getAttendeeEmail(attendee),
247 const equivalentAttendees = groupWith((a, b) => a.token === b.token, attendeesWithToken).map((group) =>
248 group.map(({ email }) => email)
250 return equivalentAttendees.length < attendees.length
251 ? equivalentAttendees.filter((group) => group.length > 1)
254 // not all attendees have token, so we're gonna canonicalize emails and compare based on that
255 const attendeesWithCanonicalEmail = attendees.map((attendee) => {
256 const email = getAttendeeEmail(attendee);
257 const canonicalEmail = canonicalizeEmailByGuess(email);
258 return { email, canonicalEmail };
260 const equivalentAttendees = groupWith(
261 (a, b) => a.canonicalEmail === b.canonicalEmail,
262 attendeesWithCanonicalEmail
263 ).map((group) => group.map(({ email }) => email));
264 return equivalentAttendees.length < attendees.length
265 ? equivalentAttendees.filter((group) => group.length > 1)
269 const { REQUIRED } = ICAL_ATTENDEE_ROLE;
270 const { TRUE } = ICAL_ATTENDEE_RSVP;
271 const { NEEDS_ACTION } = ICAL_ATTENDEE_STATUS;
273 export const emailToAttendee = (email: string): AttendeeModel => ({
277 partstat: NEEDS_ACTION,