Cleanup - unused files / unused exports / duplicate exports
[ProtonMail-WebClient.git] / packages / shared / lib / helpers / email.ts
blob4e1e6d64e1b9e52d4c3bc8d259b919548aa6f758
1 /**
2  * Validate the local part of an email string according to the RFC https://tools.ietf.org/html/rfc5321#section-4.1.2;
3  * see also https://tools.ietf.org/html/rfc3696#page-5 and https://en.wikipedia.org/wiki/Email_address#Local-part
4  *
5  * NOTE: Email providers respect the RFC only loosely. We do not want to invalidate addresses that would be accepted by the BE.
6  * It is not fully guaranteed that this helper is currently accepting everything that the BE accepts.
7  *
8  * Examples of RFC rules violated in the wild:
9  * * Local parts should have a maximum length of 64 octets
10  */
11 import isTruthy from '@proton/utils/isTruthy';
13 export enum CANONICALIZE_SCHEME {
14     DEFAULT,
15     PLUS,
16     GMAIL,
17     PROTON,
20 export const PROTONMAIL_DOMAINS = ['protonmail.com', 'protonmail.ch', 'pm.me', 'proton.me'];
22 export const validateLocalPart = (localPart: string) => {
23     // remove comments first
24     const match = localPart.match(/(^\(.+?\))?([^()]*)(\(.+?\)$)?/);
25     if (!match) {
26         return false;
27     }
28     const uncommentedPart = match[2];
29     if (/^".+"$/.test(uncommentedPart)) {
30         // case of a quoted string
31         // The only characters non-allowed are \ and " unless preceded by a backslash
32         const quotedText = uncommentedPart.slice(1, -1);
33         const chunks = quotedText
34             .split('\\"')
35             .map((chunk) => chunk.split('\\\\'))
36             .flat();
37         return !chunks.some((chunk) => /"|\\/.test(chunk));
38     }
39     return !/[^a-zA-Z0-9!#$%&'*+/=?^_`{|}~.-]|^\.|\.$|\.\./.test(uncommentedPart);
42 /**
43  * Validate the domain of an email string according to the preferred name syntax of the RFC https://tools.ietf.org/html/rfc1034.
44  * Actually almost anything is allowed as domain name https://tools.ietf.org/html/rfc2181#section-11, but we stick
45  * to the preferred one, allowing undescores which are common in the wild.
46  * See also https://en.wikipedia.org/wiki/Email_address#Domain
47  */
48 export const validateDomain = (domain: string) => {
49     if (domain.length > 255) {
50         return false;
51     }
52     const domainRegex =
53         /^((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+([a-zA-Z]{2,}[0-9]*|xn--[a-zA-Z\-0-9]+)))$/;
54     if (domainRegex.test(domain)) {
55         return true;
56     }
57     const dnsLabels = domain.toLowerCase().split('.').filter(isTruthy);
58     if (dnsLabels.length < 2) {
59         return false;
60     }
61     const topLevelDomain = dnsLabels.pop() as string;
62     if (!/^[a-z0-9]+$/.test(topLevelDomain)) {
63         return false;
64     }
65     return !dnsLabels.some((label) => {
66         return /[^a-z0-9-_]|^-|-$/.test(label);
67     });
70 /**
71  * Split an email into local part plus domain.
72  */
73 export const getEmailParts = (email: string): [localPart: string, domain: string] => {
74     const endIdx = email.lastIndexOf('@');
75     if (endIdx === -1) {
76         return [email, ''];
77     }
78     return [email.slice(0, endIdx), email.slice(endIdx + 1)];
81 /**
82  * Validate an email string according to the RFC https://tools.ietf.org/html/rfc5322;
83  * see also https://en.wikipedia.org/wiki/Email_address
84  */
85 export const validateEmailAddress = (email: string) => {
86     const [localPart, domain] = getEmailParts(email);
87     if (!localPart || !domain) {
88         return false;
89     }
90     return validateLocalPart(localPart) && validateDomain(domain);
93 export const removePlusAliasLocalPart = (localPart = '') => {
94     const [cleanLocalPart] = localPart.split('+');
95     return cleanLocalPart;
98 /**
99  * Add plus alias part for an email
100  */
101 export const addPlusAlias = (email = '', plus = '') => {
102     const atIndex = email.indexOf('@');
103     const plusIndex = email.indexOf('+');
105     if (atIndex === -1 || plusIndex > -1) {
106         return email;
107     }
109     const name = email.substring(0, atIndex);
110     const domain = email.substring(atIndex, email.length);
112     return `${name}+${plus}${domain}`;
116  * Canonicalize an email address following one of the known schemes
117  * Emails that have the same canonical form end up in the same inbox
118  * See https://confluence.protontech.ch/display/MBE/Canonize+email+addresses
119  */
120 export const canonicalizeEmail = (email: string, scheme = CANONICALIZE_SCHEME.DEFAULT) => {
121     const [localPart, domain] = getEmailParts(email);
122     const at = email[email.length - domain.length - 1] === '@' ? '@' : '';
123     if (scheme === CANONICALIZE_SCHEME.PROTON) {
124         const cleanLocalPart = removePlusAliasLocalPart(localPart);
125         const normalizedLocalPart = cleanLocalPart.replace(/[._-]/g, '').toLowerCase(); // Remove dots, underscores, and hyphens
126         const normalizedDomain = domain.toLowerCase();
128         return `${normalizedLocalPart}${at}${normalizedDomain}`;
129     }
130     if (scheme === CANONICALIZE_SCHEME.GMAIL) {
131         const cleanLocalPart = removePlusAliasLocalPart(localPart);
132         const normalizedLocalPart = cleanLocalPart.replace(/[.]/g, '').toLowerCase(); // Remove dots
133         const normalizedDomain = domain.toLowerCase();
135         return `${normalizedLocalPart}${at}${normalizedDomain}`;
136     }
137     if (scheme === CANONICALIZE_SCHEME.PLUS) {
138         const cleanLocalPart = removePlusAliasLocalPart(localPart);
139         const normalizedLocalPart = cleanLocalPart.toLowerCase();
140         const normalizedDomain = domain.toLowerCase();
142         return `${normalizedLocalPart}${at}${normalizedDomain}`;
143     }
145     return email.toLowerCase();
148 export const canonicalizeInternalEmail = (email: string) => canonicalizeEmail(email, CANONICALIZE_SCHEME.PROTON);
151  * Canonicalize an email by guessing the scheme that should be applied
152  * Notice that this helper will not apply the Proton scheme on custom domains;
153  * Only the back-end knows about custom domains, but they also apply the default scheme in those cases.
154  */
155 export const canonicalizeEmailByGuess = (email: string) => {
156     const [, domain] = getEmailParts(email);
157     const normalizedDomain = domain.toLowerCase();
158     if (PROTONMAIL_DOMAINS.includes(normalizedDomain)) {
159         return canonicalizeEmail(email, CANONICALIZE_SCHEME.PROTON);
160     }
161     if (['gmail.com', 'googlemail.com', 'google.com'].includes(normalizedDomain)) {
162         return canonicalizeEmail(email, CANONICALIZE_SCHEME.GMAIL);
163     }
164     if (
165         ['hotmail.com', 'hotmail.co.uk', 'hotmail.fr', 'outlook.com', 'yandex.ru', 'mail.ru'].includes(normalizedDomain)
166     ) {
167         return canonicalizeEmail(email, CANONICALIZE_SCHEME.PLUS);
168     }
169     return canonicalizeEmail(email, CANONICALIZE_SCHEME.DEFAULT);
172 const extractStringItems = (str: string) => {
173     // filter(isTruthy) wouldn't result in TS understanding that the return of this function is of type string[], so we expand it
174     return str.split(',').filter((item) => isTruthy(item));
178  * Try to decode an URI string with the native decodeURI function.
179  * Return the original string if decoding fails
180  */
181 const decodeURISafe = (str: string) => {
182     try {
183         return decodeURI(str);
184     } catch (e: any) {
185         return str;
186     }
190  * Extract "to address" and headers from a mailto URL https://tools.ietf.org/html/rfc6068
191  */
192 export const parseMailtoURL = (mailtoURL: string, decode = true) => {
193     const mailtoString = 'mailto:';
194     const toString = 'to=';
195     if (!mailtoURL.toLowerCase().startsWith(mailtoString)) {
196         throw new Error('Malformed mailto URL');
197     }
198     const url = mailtoURL.substring(mailtoString.length);
199     const [tos, hfields = ''] = url.split('?');
200     const addressTos = extractStringItems(tos).map((to) => (decode ? decodeURISafe(to) : to));
201     const headers = hfields.split('&').filter(isTruthy);
202     const headerTos = headers
203         .filter((header) => header.toLowerCase().startsWith('to='))
204         .map((headerTo) => extractStringItems(headerTo.substring(toString.length)))
205         .flat()
206         .map((to) => (decode ? decodeURISafe(to) : to));
207     return { to: [...addressTos, ...headerTos] };
210 export const buildMailTo = (email = '') => `mailto:${email}`;
212 export const getEmailTo = (str: string, decode?: boolean) => {
213     try {
214         const {
215             to: [emailTo = ''],
216         } = parseMailtoURL(str, decode);
217         return emailTo;
218     } catch (e: any) {
219         return str;
220     }
223 export function extractEmailFromUserID(userID: string): string | undefined {
224     const [, email] = /<([^>]*)>/.exec(userID) || [];
225     return email;
228 export const isNoReplyEmail = (email: string) => {
229     const normalizedEmail = canonicalizeEmailByGuess(email);
230     const [localPart] = getEmailParts(normalizedEmail);
231     const normalizedLocalPart = localPart.replace(/[._-]/g, ''); // Remove dots, underscores, and hyphens
232     return normalizedLocalPart.includes('noreply') || normalizedLocalPart.includes('donotreply');