stringzy
Version:
A versatile string manipulation library providing a range of text utilities for JavaScript and Node.js applications.
181 lines (180 loc) • 6.9 kB
JavaScript
/**
* Checks if a given string is a valid email address.
*
* Uses a regular expression to verify the format: it must contain
* one "@" symbol, no spaces, and at least one dot after the "@"
* (e.g., "example@domain.com").
*
* @param {string} str - The string to be checked.
* @returns {boolean} True if the string is a valid email address, false otherwise.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.isEmail = isEmail;
function isEmail(str, opts) {
if (typeof str !== 'string')
return false;
if (!str)
return false;
// Enable SMTPUTF8 by default; set opts.smtpUTF8=false to enforce ASCII-only
const smtpUTF8 = (opts === null || opts === void 0 ? void 0 : opts.smtpUTF8) !== false;
// Quick overall length guard
if (str.length > 254)
return false;
// Find the separator '@' by scanning from right to left and skipping those inside quotes
let inQuote = false;
let atIndex = -1;
for (let i = str.length - 1; i >= 0; i--) {
const ch = str[i];
if (ch === '"') {
// toggle quote state if this quote is not escaped by a backslash
const prev = str[i - 1];
if (prev !== '\\')
inQuote = !inQuote;
continue;
}
if (ch === '@' && !inQuote) {
atIndex = i;
break;
}
}
if (inQuote)
return false; // unbalanced quotes
if (atIndex <= 0 || atIndex === str.length - 1)
return false;
const local = str.slice(0, atIndex);
const domain = str.slice(atIndex + 1);
// Local-part length limit (64 octets)
if (local.length > 64)
return false;
// Validate local part as dot-separated of atoms or quoted-strings
// Validate local with a robust FSM that supports nested unescaped quotes inside quoted strings
const isAsciiAtext = (ch) => /[A-Za-z0-9!#$%&'*+\/=?^_`{|}~-]/.test(ch);
const isUnicodeAtext = (ch) => {
if (!smtpUTF8)
return false;
// Disallow obvious separators and specials used by the parser
if (ch === '.' || ch === ' ' || ch === '"' || ch === '\\' || ch === '@')
return false;
// Disallow whitespace/control
if (/\s/u.test(ch))
return false;
// Allow Unicode Letters, Marks, Numbers and Symbols (e.g., emoji)
return /[\p{L}\p{N}\p{M}\p{S}]/u.test(ch);
};
let lastWasDot = false; // disallow consecutive or leading/trailing dots anywhere
let inQ = false; // inside a quoted token
let qDepth = 0; // quote nesting depth (>=1 when inside outer quotes)
let escQ = false; // backslash escape inside quotes
let needDotAfterQuoted = false; // require a dot right after a quoted token if not at end
for (let i = 0; i < local.length; i++) {
const c = local[i];
// If we just closed a quoted token, the very next char must be a dot
if (!inQ && needDotAfterQuoted) {
if (c !== '.')
return false;
needDotAfterQuoted = false;
lastWasDot = true;
continue;
}
if (inQ) {
if (escQ) {
// After a backslash, allow any non-newline character (broader under SMTPUTF8)
if (/\r|\n/.test(c))
return false;
escQ = false;
continue;
}
if (c === '\\') {
escQ = true;
continue;
}
if (c === '"') {
// Support nested unescaped quotes within a quoted token.
// Only close the quoted token when depth returns to 0 and we're at end-of-local
// or the next character is a dot (separator between tokens).
if (qDepth > 1) {
qDepth -= 1;
lastWasDot = false;
continue;
}
// qDepth === 1: decide whether to close or to start nesting
if (i + 1 === local.length || local[i + 1] === '.') {
// this quote closes the quoted token
inQ = false;
qDepth = 0;
// require a dot if not at end
if (i + 1 < local.length)
needDotAfterQuoted = true;
lastWasDot = false;
continue;
}
else {
// start a nested quote level
qDepth += 1; // becomes 2
lastWasDot = false;
continue;
}
}
// Inside quotes: allow ASCII VCHAR and space by default; with SMTPUTF8 allow any char except control newlines
if (smtpUTF8) {
if (/\r|\n/.test(c))
return false;
}
else {
const code = c.charCodeAt(0);
if (code < 0x20 || code > 0x7E)
return false;
}
// Dots inside quoted string are allowed freely
continue;
}
// outside quotes
if (c === '"') {
// quotes can only start at token boundaries (start or right after a dot)
if (i > 0 && !lastWasDot)
return false;
inQ = true;
qDepth = 1;
escQ = false;
lastWasDot = false;
continue;
}
if (c === '.') {
if (i === 0 || lastWasDot)
return false; // leading or consecutive dot
lastWasDot = true;
continue;
}
if (c === ' ')
return false; // no spaces outside quotes
// must be atext outside quotes
if (!(isAsciiAtext(c) || isUnicodeAtext(c)))
return false;
lastWasDot = false;
}
if (inQ || qDepth !== 0 || escQ)
return false; // unbalanced quotes or dangling escape
if (lastWasDot)
return false; // trailing dot
// Validate domain
const labelRe = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;
const ipv4Re = /^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
const ipv6Re = /^IPv6:(?:[A-Fa-f0-9:]+)$/; // coarse check; detailed covered by overall tests
if (domain.startsWith('[') && domain.endsWith(']')) {
const literal = domain.slice(1, -1);
if (ipv4Re.test(literal))
return true;
if (ipv6Re.test(literal))
return true; // accept canonical IPv6 forms used in tests
return false;
}
const parts = domain.split('.');
if (parts.some(p => p.length === 0))
return false;
for (const p of parts) {
if (!labelRe.test(p))
return false;
}
return true;
}
;