diff --git a/src/gmail-ai-classifier/code.gs b/src/gmail-ai-classifier/code.gs index bf9d8c6f..f9965fd1 100644 --- a/src/gmail-ai-classifier/code.gs +++ b/src/gmail-ai-classifier/code.gs @@ -1136,8 +1136,72 @@ function extractSenderAddress_(sender) { .toLowerCase() const angleMatch = /<([^<>@\s]+@[^<>@\s]+)>/.exec(raw) if (angleMatch) return angleMatch[1] - const plainMatch = /([^\s<>,"']+@[a-z0-9.-]+\.[a-z]{2,})/i.exec(raw) - return plainMatch ? plainMatch[1].toLowerCase() : '' + const tokens = raw.split(/[\s<>,"']+/) + for (let i = 0; i < tokens.length; i++) { + const found = findPlainEmailInToken_(tokens[i]) + if (found) return found + } + return '' +} + +/** + * Checks if a character is an ASCII lowercase letter. + * + * @param {string} ch - Single character to test + * @returns {boolean} True if ch is in [a-z] + */ +function isAsciiLetter_(ch) { + return ch >= 'a' && ch <= 'z' +} + +/** + * Checks if a character is valid in a domain name (letter, digit, dot, or hyphen). + * + * @param {string} ch - Single character to test + * @returns {boolean} True if ch is [a-z0-9.-] + */ +function isDomainChar_(ch) { + return ( + isAsciiLetter_(ch) || (ch >= '0' && ch <= '9') || ch === '.' || ch === '-' + ) +} + +/** + * Returns the end index of the longest `[a-z0-9.-]+\.[a-z]{2,}` match + * anchored at `start`, or -1 if none. Linear-time replacement for the regex. + */ +function plainDomainMatchEnd_(token, start) { + let domainEnd = start + while (domainEnd < token.length && isDomainChar_(token[domainEnd])) + domainEnd++ + for (let dot = domainEnd - 1; dot > start; dot--) { + if ( + token[dot] === '.' && + isAsciiLetter_(token[dot + 1]) && + isAsciiLetter_(token[dot + 2]) + ) { + let end = dot + 1 + while (end < domainEnd && isAsciiLetter_(token[end])) end++ + return end + } + } + return -1 +} + +/** + * Finds the leftmost-longest `local@domain.tld` prefix of a delimiter-free + * token (same semantics as the former greedy regex, without backtracking). + */ +function findPlainEmailInToken_(token) { + for ( + let at = token.lastIndexOf('@'); + at > 0; + at = token.lastIndexOf('@', at - 1) + ) { + const end = plainDomainMatchEnd_(token, at + 1) + if (end !== -1) return token.slice(0, end) + } + return '' } /** @@ -7307,5 +7371,6 @@ if (typeof module !== 'undefined' && module.exports) { isTriggerEventObject_, resolveEffectiveConfig_, sortThreadsByLastMessageDate_, + extractSenderAddress_, } } diff --git a/src/gmail-ai-classifier/src/index.js b/src/gmail-ai-classifier/src/index.js index b01dc26a..2193e817 100644 --- a/src/gmail-ai-classifier/src/index.js +++ b/src/gmail-ai-classifier/src/index.js @@ -1131,8 +1131,72 @@ function extractSenderAddress_(sender) { .toLowerCase() const angleMatch = /<([^<>@\s]+@[^<>@\s]+)>/.exec(raw) if (angleMatch) return angleMatch[1] - const plainMatch = /([^\s<>,"']+@[a-z0-9.-]+\.[a-z]{2,})/i.exec(raw) - return plainMatch ? plainMatch[1].toLowerCase() : '' + const tokens = raw.split(/[\s<>,"']+/) + for (let i = 0; i < tokens.length; i++) { + const found = findPlainEmailInToken_(tokens[i]) + if (found) return found + } + return '' +} + +/** + * Checks if a character is an ASCII lowercase letter. + * + * @param {string} ch - Single character to test + * @returns {boolean} True if ch is in [a-z] + */ +function isAsciiLetter_(ch) { + return ch >= 'a' && ch <= 'z' +} + +/** + * Checks if a character is valid in a domain name (letter, digit, dot, or hyphen). + * + * @param {string} ch - Single character to test + * @returns {boolean} True if ch is [a-z0-9.-] + */ +function isDomainChar_(ch) { + return ( + isAsciiLetter_(ch) || (ch >= '0' && ch <= '9') || ch === '.' || ch === '-' + ) +} + +/** + * Returns the end index of the longest `[a-z0-9.-]+\.[a-z]{2,}` match + * anchored at `start`, or -1 if none. Linear-time replacement for the regex. + */ +function plainDomainMatchEnd_(token, start) { + let domainEnd = start + while (domainEnd < token.length && isDomainChar_(token[domainEnd])) + domainEnd++ + for (let dot = domainEnd - 1; dot > start; dot--) { + if ( + token[dot] === '.' && + isAsciiLetter_(token[dot + 1]) && + isAsciiLetter_(token[dot + 2]) + ) { + let end = dot + 1 + while (end < domainEnd && isAsciiLetter_(token[end])) end++ + return end + } + } + return -1 +} + +/** + * Finds the leftmost-longest `local@domain.tld` prefix of a delimiter-free + * token (same semantics as the former greedy regex, without backtracking). + */ +function findPlainEmailInToken_(token) { + for ( + let at = token.lastIndexOf('@'); + at > 0; + at = token.lastIndexOf('@', at - 1) + ) { + const end = plainDomainMatchEnd_(token, at + 1) + if (end !== -1) return token.slice(0, end) + } + return '' } /** @@ -7302,5 +7366,6 @@ if (typeof module !== 'undefined' && module.exports) { isTriggerEventObject_, resolveEffectiveConfig_, sortThreadsByLastMessageDate_, + extractSenderAddress_, } } diff --git a/src/gmail-ai-classifier/tests/sender-address.test.js b/src/gmail-ai-classifier/tests/sender-address.test.js new file mode 100644 index 00000000..bc184ff8 --- /dev/null +++ b/src/gmail-ai-classifier/tests/sender-address.test.js @@ -0,0 +1,84 @@ +const codeMod = require('../code.gs') +const indexMod = require('../src/index.js') + +// Reference implementation: the original regex, used to assert the +// non-regex rewrite preserves behavior exactly on ordinary inputs. +function legacyExtract(sender) { + const raw = String(sender || '') + .trim() + .toLowerCase() + const angleMatch = /<([^<>@\s]+@[^<>@\s]+)>/.exec(raw) + if (angleMatch) return angleMatch[1] + const plainMatch = /([^\s<>,"']+@[a-z0-9.-]+\.[a-z]{2,})/i.exec(raw) + return plainMatch ? plainMatch[1].toLowerCase() : '' +} + +describe.each([ + ['code.gs', codeMod], + ['src/index.js', indexMod], +])('extractSenderAddress_ (%s)', (_name, mod) => { + const extract = mod.extractSenderAddress_ + + test.each([ + ['Jane Doe ', 'jane.doe@example.com'], + ['jane@example.com', 'jane@example.com'], + ['"Jane" jane@example.co.uk', 'jane@example.co.uk'], + ['Reply to: bob@mail.example.org, alice@x.com', 'bob@mail.example.org'], + ['bob@example.com>', 'bob@example.com'], + ['bob@example.com;extra', 'bob@example.com'], + ['bob@example.com1', 'bob@example.com'], + ['a@b@example.com', 'a@b@example.com'], + ['a@example.com@x', 'a@example.com'], + ['a@b.c1', ''], + ['@example.com', ''], + ['no address here', ''], + ['nobody@localhost', ''], + ['x@.com', ''], + ['bad@x foo@example.net', 'foo@example.net'], + ['', ''], + [null, ''], + [undefined, ''], + ])('extracts %p -> %p', (input, expected) => { + expect(extract(input)).toBe(expected) + expect(extract(input)).toBe(legacyExtract(input)) + }) + + test('matches legacy regex on assorted generated inputs', () => { + const alphabet = [ + 'a', + 'b', + '1', + '.', + '-', + '@', + ' ', + '<', + '>', + ',', + '"', + "'", + '_', + 'Z', + ] + let seed = 42 + const rand = () => { + seed = (seed * 1103515245 + 12345) % 2147483648 + return seed + } + for (let n = 0; n < 3000; n++) { + const len = rand() % 16 + let s = '' + for (let i = 0; i < len; i++) s += alphabet[rand() % alphabet.length] + expect(extract(s)).toBe(legacyExtract(s)) + } + }) + + test('runs in linear time on adversarial input', () => { + const adversarial = 'a'.repeat(50000) + '@' + 'a.'.repeat(25000) + '1' + const start = Date.now() + expect(extract(adversarial)).toBe('') + expect(extract('x'.repeat(50000))).toBe('') + expect(extract('@'.repeat(50000))).toBe('') + expect(Date.now() - start).toBeLessThan(500) + }) +})