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
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.
8 * Examples of RFC rules violated in the wild:
9 * * Local parts should have a maximum length of 64 octets
11 import isTruthy from '@proton/utils/isTruthy';
13 export enum CANONICALIZE_SCHEME {
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(/(^\(.+?\))?([^()]*)(\(.+?\)$)?/);
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
35 .map((chunk) => chunk.split('\\\\'))
37 return !chunks.some((chunk) => /"|\\/.test(chunk));
39 return !/[^a-zA-Z0-9!#$%&'*+/=?^_`{|}~.-]|^\.|\.$|\.\./.test(uncommentedPart);
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
48 export const validateDomain = (domain: string) => {
49 if (domain.length > 255) {
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)) {
57 const dnsLabels = domain.toLowerCase().split('.').filter(isTruthy);
58 if (dnsLabels.length < 2) {
61 const topLevelDomain = dnsLabels.pop() as string;
62 if (!/^[a-z0-9]+$/.test(topLevelDomain)) {
65 return !dnsLabels.some((label) => {
66 return /[^a-z0-9-_]|^-|-$/.test(label);
71 * Split an email into local part plus domain.
73 export const getEmailParts = (email: string): [localPart: string, domain: string] => {
74 const endIdx = email.lastIndexOf('@');
78 return [email.slice(0, endIdx), email.slice(endIdx + 1)];
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
85 export const validateEmailAddress = (email: string) => {
86 const [localPart, domain] = getEmailParts(email);
87 if (!localPart || !domain) {
90 return validateLocalPart(localPart) && validateDomain(domain);
93 export const removePlusAliasLocalPart = (localPart = '') => {
94 const [cleanLocalPart] = localPart.split('+');
95 return cleanLocalPart;
99 * Add plus alias part for an email
101 export const addPlusAlias = (email = '', plus = '') => {
102 const atIndex = email.indexOf('@');
103 const plusIndex = email.indexOf('+');
105 if (atIndex === -1 || plusIndex > -1) {
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
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}`;
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}`;
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}`;
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.
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);
161 if (['gmail.com', 'googlemail.com', 'google.com'].includes(normalizedDomain)) {
162 return canonicalizeEmail(email, CANONICALIZE_SCHEME.GMAIL);
165 ['hotmail.com', 'hotmail.co.uk', 'hotmail.fr', 'outlook.com', 'yandex.ru', 'mail.ru'].includes(normalizedDomain)
167 return canonicalizeEmail(email, CANONICALIZE_SCHEME.PLUS);
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
181 const decodeURISafe = (str: string) => {
183 return decodeURI(str);
190 * Extract "to address" and headers from a mailto URL https://tools.ietf.org/html/rfc6068
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');
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)))
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) => {
216 } = parseMailtoURL(str, decode);
223 export function extractEmailFromUserID(userID: string): string | undefined {
224 const [, email] = /<([^>]*)>/.exec(userID) || [];
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');