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 NO_CANONICAL_EMAIL_ERROR = 'No canonical email provided';
26 export const generateAttendeeToken = async (normalizedEmail: string, uid: string) => {
27 const uidEmail = `${uid}${normalizedEmail}`;
28 const byteArray = binaryStringToArray(uidEmail);
29 const hash = await CryptoProxy.computeHash({ algorithm: 'unsafeSHA1', data: byteArray });
30 return arrayToHexString(hash);
33 export const toApiPartstat = (partstat?: string) => {
34 if (partstat === ICAL_ATTENDEE_STATUS.TENTATIVE) {
35 return ATTENDEE_STATUS_API.TENTATIVE;
37 if (partstat === ICAL_ATTENDEE_STATUS.ACCEPTED) {
38 return ATTENDEE_STATUS_API.ACCEPTED;
40 if (partstat === ICAL_ATTENDEE_STATUS.DECLINED) {
41 return ATTENDEE_STATUS_API.DECLINED;
43 return ATTENDEE_STATUS_API.NEEDS_ACTION;
46 export const toIcsPartstat = (partstat?: ATTENDEE_STATUS_API) => {
47 if (partstat === ATTENDEE_STATUS_API.TENTATIVE) {
48 return ICAL_ATTENDEE_STATUS.TENTATIVE;
50 if (partstat === ATTENDEE_STATUS_API.ACCEPTED) {
51 return ICAL_ATTENDEE_STATUS.ACCEPTED;
53 if (partstat === ATTENDEE_STATUS_API.DECLINED) {
54 return ICAL_ATTENDEE_STATUS.DECLINED;
56 return ICAL_ATTENDEE_STATUS.NEEDS_ACTION;
59 export const fromInternalAttendee = ({
60 parameters: { 'x-pm-token': token = '', partstat, ...restParameters } = {},
62 }: VcalAttendeeProperty) => {
73 status: toApiPartstat(partstat),
78 export const toInternalAttendee = (
79 { attendee: attendees = [] }: Pick<VcalVeventComponent, 'attendee'>,
80 clear: Attendee[] = []
81 ): VcalAttendeeProperty[] => {
82 return attendees.map((attendee) => {
83 if (!attendee.parameters) {
86 const token = attendee.parameters['x-pm-token'];
87 const extra = clear.find(({ Token }) => Token === token);
88 if (!token || !extra) {
91 const partstat = toIcsPartstat(extra.Status);
95 ...attendee.parameters,
102 export const getAttendeeEmail = (attendee: VcalAttendeeProperty | VcalOrganizerProperty) => {
103 const { cn, email } = attendee.parameters || {};
104 const emailTo = getEmailTo(attendee.value);
105 if (validateEmailAddress(emailTo)) {
108 if (email && validateEmailAddress(email)) {
111 if (cn && validateEmailAddress(cn)) {
117 export const withPartstat = (attendee: VcalAttendeeProperty, partstat?: ICAL_ATTENDEE_STATUS) => ({
120 ...attendee.parameters,
125 export const modifyAttendeesPartstat = (
126 attendees: VcalAttendeeProperty[],
127 partstatMap: SimpleMap<ICAL_ATTENDEE_STATUS>
129 const emailsToModify = Object.keys(partstatMap);
130 return attendees.map((attendee) => {
131 const email = getAttendeeEmail(attendee);
132 if (!emailsToModify.includes(email)) {
135 return withPartstat(attendee, partstatMap[email]);
139 export const getSupportedOrganizer = (organizer: VcalOrganizerProperty) => {
140 const { parameters: { cn } = {} } = organizer;
141 const emailAddress = getAttendeeEmail(organizer);
142 const supportedOrganizer: RequireSome<VcalAttendeeProperty, 'parameters'> = {
143 value: buildMailTo(emailAddress),
145 cn: truncatePossiblyQuotedString(cn ?? emailAddress, CONTACT_NAME_MAX_LENGTH),
149 return supportedOrganizer;
152 export const getSupportedAttendee = (attendee: VcalAttendeeProperty) => {
153 const { parameters: { cn, role, partstat, rsvp, 'x-pm-token': token } = {} } = attendee;
154 const emailAddress = getAttendeeEmail(attendee);
155 const supportedAttendee: RequireSome<VcalAttendeeProperty, 'parameters'> = {
156 value: buildMailTo(emailAddress),
158 cn: truncatePossiblyQuotedString(cn ?? emailAddress, CONTACT_NAME_MAX_LENGTH),
161 const normalizedUppercasedRole = normalize(role).toUpperCase();
164 normalizedUppercasedRole === ICAL_ATTENDEE_ROLE.REQUIRED ||
165 normalizedUppercasedRole === ICAL_ATTENDEE_ROLE.OPTIONAL
167 supportedAttendee.parameters.role = normalizedUppercasedRole;
170 if (normalize(rsvp) === 'true') {
171 supportedAttendee.parameters.rsvp = 'TRUE';
175 supportedAttendee.parameters.partstat = getAttendeePartstat(attendee);
178 if (token?.length === 40) {
179 supportedAttendee.parameters['x-pm-token'] = token;
182 return supportedAttendee;
185 export const getCanonicalEmails = async (
186 attendees: VcalAttendeeProperty[] = [],
187 getCanonicalEmailsMap: GetCanonicalEmailsMap
189 return Object.values(await getCanonicalEmailsMap(attendees.map(unary(getAttendeeEmail)))).filter(isTruthy);
192 export const withPmAttendees = async (
193 vevent: VcalVeventComponent,
194 getCanonicalEmailsMap: GetCanonicalEmailsMap,
196 ): Promise<VcalPmVeventComponent> => {
197 const { uid, attendee: vcalAttendee } = vevent;
198 if (!vcalAttendee?.length) {
199 return omit(vevent, ['attendee']);
201 const attendeesWithEmail = vcalAttendee.map((attendee) => {
202 const emailAddress = getAttendeeEmail(attendee);
208 const emailsWithoutToken = attendeesWithEmail
209 .filter(({ attendee }) => !attendee.parameters?.['x-pm-token'])
210 .map(({ emailAddress }) => emailAddress);
211 const canonicalEmailMap = await getCanonicalEmailsMap(emailsWithoutToken);
213 const pmAttendees = await Promise.all(
214 attendeesWithEmail.map(async ({ attendee, emailAddress }) => {
215 const supportedAttendee = getSupportedAttendee(attendee);
216 if (getAttendeeHasToken(supportedAttendee)) {
217 return supportedAttendee;
219 const canonicalEmail = canonicalEmailMap[emailAddress];
220 if (!canonicalEmail && !ignoreErrors) {
221 throw new Error(NO_CANONICAL_EMAIL_ERROR);
223 // If the participant has an invalid email and we ignore errors, we fall back to the provided email address
224 const token = await generateAttendeeToken(canonicalEmail || emailAddress, uid.value);
226 ...supportedAttendee,
228 ...supportedAttendee.parameters,
236 attendee: pmAttendees,
240 export const getEquivalentAttendees = (attendees?: VcalAttendeeProperty[]) => {
241 if (!attendees?.length) {
244 if (getAttendeesHaveToken(attendees)) {
245 const attendeesWithToken = attendees.map((attendee) => ({
246 token: attendee.parameters['x-pm-token'],
247 email: getAttendeeEmail(attendee),
249 const equivalentAttendees = groupWith((a, b) => a.token === b.token, attendeesWithToken).map((group) =>
250 group.map(({ email }) => email)
252 return equivalentAttendees.length < attendees.length
253 ? equivalentAttendees.filter((group) => group.length > 1)
256 // not all attendees have token, so we're gonna canonicalize emails and compare based on that
257 const attendeesWithCanonicalEmail = attendees.map((attendee) => {
258 const email = getAttendeeEmail(attendee);
259 const canonicalEmail = canonicalizeEmailByGuess(email);
260 return { email, canonicalEmail };
262 const equivalentAttendees = groupWith(
263 (a, b) => a.canonicalEmail === b.canonicalEmail,
264 attendeesWithCanonicalEmail
265 ).map((group) => group.map(({ email }) => email));
266 return equivalentAttendees.length < attendees.length
267 ? equivalentAttendees.filter((group) => group.length > 1)
271 const { REQUIRED } = ICAL_ATTENDEE_ROLE;
272 const { TRUE } = ICAL_ATTENDEE_RSVP;
273 const { NEEDS_ACTION } = ICAL_ATTENDEE_STATUS;
275 export const emailToAttendee = (email: string): AttendeeModel => ({
279 partstat: NEEDS_ACTION,