Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 67 additions & 2 deletions src/gmail-ai-classifier/code.gs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ''
}

/**
Expand Down Expand Up @@ -7307,5 +7371,6 @@ if (typeof module !== 'undefined' && module.exports) {
isTriggerEventObject_,
resolveEffectiveConfig_,
sortThreadsByLastMessageDate_,
extractSenderAddress_,
}
}
69 changes: 67 additions & 2 deletions src/gmail-ai-classifier/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ''
}

/**
Expand Down Expand Up @@ -7302,5 +7366,6 @@ if (typeof module !== 'undefined' && module.exports) {
isTriggerEventObject_,
resolveEffectiveConfig_,
sortThreadsByLastMessageDate_,
extractSenderAddress_,
}
}
84 changes: 84 additions & 0 deletions src/gmail-ai-classifier/tests/sender-address.test.js
Original file line number Diff line number Diff line change
@@ -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.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)
})
})
Loading