diff --git a/cloudflare-temp-email-utils.js b/cloudflare-temp-email-utils.js index 75a10bc..08f63eb 100644 --- a/cloudflare-temp-email-utils.js +++ b/cloudflare-temp-email-utils.js @@ -11,6 +11,7 @@ function firstNonEmptyString(values) { for (const value of values) { if (value === undefined || value === null) continue; + if (typeof value === 'object') continue; const normalized = String(value).trim(); if (normalized) return normalized; } @@ -87,18 +88,35 @@ if (Array.isArray(payload)) return payload; if (!payload || typeof payload !== 'object') return []; - const candidates = [ - payload.data, - payload.items, - payload.messages, - payload.mails, - payload.results, - payload.rows, + const rowKeys = [ + 'data', + 'items', + 'messages', + 'mails', + 'results', + 'result', + 'rows', + 'list', + 'hydra:member', ]; + const queue = [payload]; + const seen = new Set(); - for (const candidate of candidates) { - if (Array.isArray(candidate)) { - return candidate; + while (queue.length) { + const current = queue.shift(); + if (!current || typeof current !== 'object' || seen.has(current)) { + continue; + } + seen.add(current); + + for (const key of rowKeys) { + const candidate = current[key]; + if (Array.isArray(candidate)) { + return candidate; + } + if (candidate && typeof candidate === 'object') { + queue.push(candidate); + } } } @@ -106,7 +124,47 @@ } function normalizeCloudflareTempEmailAddress(value) { - return String(value || '').trim().toLowerCase(); + const source = String(value || '').trim(); + if (!source) return ''; + const bracketMatch = source.match(/<\s*([^<>\s]+@[^<>\s]+)\s*>/); + const directMatch = source.match(/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i); + return String(bracketMatch?.[1] || directMatch?.[0] || source).trim().toLowerCase(); + } + + function collectTextCandidates(value, keys = []) { + const results = []; + const visit = (entry) => { + if (entry === undefined || entry === null) return; + if (typeof entry === 'string' || typeof entry === 'number') { + const normalized = String(entry).trim(); + if (normalized) results.push(normalized); + return; + } + if (Array.isArray(entry)) { + entry.forEach(visit); + return; + } + if (typeof entry === 'object') { + const objectKeys = keys.length ? keys : Object.keys(entry); + for (const key of objectKeys) { + if (Object.prototype.hasOwnProperty.call(entry, key)) { + visit(entry[key]); + } + } + } + }; + visit(value); + return results; + } + + function firstTextCandidate(values, keys = []) { + for (const value of values) { + const candidates = collectTextCandidates(value, keys); + for (const candidate of candidates) { + if (candidate) return candidate; + } + } + return ''; } function splitRawMessage(raw = '') { @@ -245,19 +303,53 @@ return match ? match[1] : ''; } + function decodeHtmlEntityCode(value, radix = 10) { + const codePoint = parseInt(value, radix); + if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 0x10FFFF) { + return ' '; + } + return String.fromCodePoint(codePoint); + } + function stripHtmlTags(value = '') { return String(value || '') .replace(//gi, ' ') .replace(//gi, ' ') .replace(/<[^>]+>/g, ' ') + .replace(/&#x([0-9a-f]+);/gi, (_match, code) => decodeHtmlEntityCode(code, 16)) + .replace(/&#(\d+);/g, (_match, code) => decodeHtmlEntityCode(code, 10)) .replace(/ /gi, ' ') .replace(/&/gi, '&') .replace(/</gi, '<') .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/g, "'") .replace(/\s+/g, ' ') .trim(); } + function normalizeBodyText(value = '', options = {}) { + const { html = false } = options; + const source = String(value || '').trim(); + if (!source) return ''; + const text = html || /<\/?[a-z][\s\S]*>/i.test(source) + ? stripHtmlTags(source) + : source; + return text.replace(/\s+/g, ' ').trim(); + } + + function firstBodyTextCandidate(values, options = {}) { + const { keys = [], html = false } = options; + for (const value of values) { + const candidates = collectTextCandidates(value, keys); + for (const candidate of candidates) { + const normalized = normalizeBodyText(candidate, { html }); + if (normalized) return normalized; + } + } + return ''; + } + function decodeMimeBody(bodyText = '', headers = {}) { const contentType = String(headers['content-type'] || ''); const transferEncoding = String(headers['content-transfer-encoding'] || '').trim().toLowerCase(); @@ -321,40 +413,114 @@ function normalizeCloudflareTempEmailMessage(row = {}) { if (!row || typeof row !== 'object') return null; - const address = normalizeCloudflareTempEmailAddress(firstNonEmptyString([ + const raw = firstTextCandidate([row.raw, row.source, row.mime, row.message], [ + 'raw', + 'source', + 'mime', + 'message', + 'content', + 'value', + ]); + const parsedMime = raw ? extractTextFromMime(raw) : { headers: {}, text: '' }; + const address = normalizeCloudflareTempEmailAddress(firstTextCandidate([ row.address, row.mail_address, + row.mailAddress, row.email, row.recipient, + row.to, + row.mailTo, + row.receiver, + row.receivers, + row.envelope_to, + row.envelopeTo, + parsedMime.headers.to, + ], [ + 'emailAddress', + 'address', + 'email', + 'value', + 'recipient', + 'mailbox', + 'to', ])); - const originalRecipient = normalizeCloudflareTempEmailAddress(firstNonEmptyString([ + const originalRecipient = normalizeCloudflareTempEmailAddress(firstTextCandidate([ row.original_recipient, row.originalRecipient, row.original_recipient_email, row.originalRecipientEmail, + row.envelope_to, + row.envelopeTo, + ], [ + 'emailAddress', + 'address', + 'email', + 'value', + 'recipient', + 'mailbox', + 'to', ])); - const raw = firstNonEmptyString([row.raw, row.source, row.mime, row.message]); - const parsedMime = raw ? extractTextFromMime(raw) : { headers: {}, text: '' }; - const subject = decodeMimeEncodedWords(firstNonEmptyString([ + const subject = decodeMimeEncodedWords(firstTextCandidate([ row.subject, + row.title, parsedMime.headers.subject, ])); - const fromAddress = decodeMimeEncodedWords(firstNonEmptyString([ + const fromAddress = decodeMimeEncodedWords(firstTextCandidate([ row.from, row.sender, row.mail_from, + row.mailFrom, + row.from_email, + row.fromEmail, + row.sender_email, + row.senderEmail, parsedMime.headers.from, + ], [ + 'emailAddress', + 'address', + 'email', + 'value', + 'name', + 'from', + 'sender', ])); - const bodyPreview = firstNonEmptyString([ + const bodyText = firstBodyTextCandidate([ + row.bodyPreview, + row.snippet, row.text, + row.text_content, + row.textContent, row.preview, row.body, + row.content, + ], { + keys: ['content', 'text', 'plain', 'value', 'body', 'bodyPreview'], + }); + const htmlText = firstBodyTextCandidate([ + row.html_content, + row.htmlContent, + row.html_body, + row.htmlBody, + row.body_html, + row.bodyHtml, + row.content_html, + row.contentHtml, + row.raw_html, + row.rawHtml, + row.html, + ], { + html: true, + keys: ['content', 'html', 'value', 'body'], + }); + const bodyPreview = firstNonEmptyString([ + bodyText, + htmlText, parsedMime.text, raw, ]).replace(/\s+/g, ' ').trim(); return { - id: firstNonEmptyString([row.id, row.mail_id]), + id: firstNonEmptyString([row.id, row._id, row.mail_id, row.mailId, row.message_id, row.messageId, row.msgid]), address, originalRecipient, addressId: firstNonEmptyString([row.address_id, row.addressId]), @@ -369,10 +535,13 @@ receivedDateTime: normalizeReceivedDateTime(firstNonEmptyString([ row.receivedDateTime, row.received_at, + row.receivedAt, row.created_at, row.createdAt, row.updated_at, + row.updatedAt, row.date, + row.timestamp, ])), }; } diff --git a/hotmail-utils.js b/hotmail-utils.js index aa93a99..e6eb5c9 100644 --- a/hotmail-utils.js +++ b/hotmail-utils.js @@ -45,7 +45,9 @@ continue; } const flags = String(pattern?.flags || '').replace(/[^dgimsuvy]/g, ''); - const match = normalizedText.match(new RegExp(source, flags)); + const regex = new RegExp(source, flags); + regex.lastIndex = 0; + const match = regex.exec(normalizedText); if (!match) { continue; } @@ -252,6 +254,8 @@ excludeCodes: filters.excludeCodes, senderFilters: filters.senderFilters, subjectFilters: filters.subjectFilters, + requiredKeywords: filters.requiredKeywords, + codePatterns: filters.codePatterns, }); return { diff --git a/tests/cloudflare-temp-email-utils.test.js b/tests/cloudflare-temp-email-utils.test.js index 1edaf83..7de385c 100644 --- a/tests/cloudflare-temp-email-utils.test.js +++ b/tests/cloudflare-temp-email-utils.test.js @@ -9,6 +9,9 @@ const { normalizeCloudflareTempEmailDomains, normalizeCloudflareTempEmailMailApiMessages, } = require('../cloudflare-temp-email-utils.js'); +const { + pickVerificationMessageWithTimeFallback, +} = require('../hotmail-utils.js'); test('normalizeCloudflareTempEmailBaseUrl normalizes host and preserves path', () => { assert.equal( @@ -103,6 +106,73 @@ test('normalizeCloudflareTempEmailMailApiMessages decodes multipart quoted print assert.equal(messages[0].subject, 'Login code'); }); +test('normalizeCloudflareTempEmailMailApiMessages supports nested CFTE rows with structured AWS html mail', () => { + const messages = normalizeCloudflareTempEmailMailApiMessages({ + data: { + results: [ + { + _id: 'aws-mail-1', + to: [{ address: 'tmpjjwk205m0h@edu.email.qlhazycoder.tech' }], + from: { + emailAddress: { + address: 'no-reply@signin.aws', + }, + }, + title: '验证您的 AWS 构建者 ID 电子邮件地址', + body_html: [ + '', + '

验证码

', + '

248680

', + '', + ].join(''), + createdAt: '2026-05-22T09:41:00.000Z', + }, + ], + }, + }); + + assert.equal(messages.length, 1); + assert.equal(messages[0].id, 'aws-mail-1'); + assert.equal(messages[0].address, 'tmpjjwk205m0h@edu.email.qlhazycoder.tech'); + assert.equal(messages[0].from.emailAddress.address, 'no-reply@signin.aws'); + assert.equal(messages[0].subject, '验证您的 AWS 构建者 ID 电子邮件地址'); + assert.match(messages[0].bodyPreview, /248680/); + assert.doesNotMatch(messages[0].bodyPreview, /

{ + const messages = normalizeCloudflareTempEmailMailApiMessages({ + data: { + results: [ + { + id: 'aws-mail-2', + to: { address: 'tmpjjwk205m0h@edu.email.qlhazycoder.tech' }, + from: { address: 'no-reply@signin.aws' }, + subject: '验证您的 AWS 构建者 ID 电子邮件地址', + html: '

验证码
248680
', + receivedAt: '2026-05-22T09:41:00.000Z', + }, + ], + }, + }); + + const result = pickVerificationMessageWithTimeFallback(messages, { + afterTimestamp: Date.UTC(2026, 4, 22, 9, 40, 0), + senderFilters: ['no-reply@signin.aws', 'aws'], + subjectFilters: ['aws builder id', 'verification', '验证码', 'code', 'aws'], + requiredKeywords: ['verification', '验证码', 'code', 'aws'], + codePatterns: [ + { source: '(?:verification\\s*code|验证码|Your code is|code is)[::\\s]*(\\d{6})', flags: 'gi' }, + { source: '^\\s*(\\d{6})\\s*$', flags: 'gm' }, + { source: '>\\s*(\\d{6})\\s*<', flags: 'g' }, + ], + excludeCodes: [], + }); + + assert.equal(result.match?.message.id, 'aws-mail-2'); + assert.equal(result.match?.code, '248680'); +}); + test('getCloudflareTempEmailAddressFromResponse supports direct and nested response shapes', () => { assert.equal(getCloudflareTempEmailAddressFromResponse({ address: 'one@example.com' }), 'one@example.com'); assert.equal(getCloudflareTempEmailAddressFromResponse({ data: { address: 'two@example.com' } }), 'two@example.com'); diff --git a/tests/hotmail-utils.test.js b/tests/hotmail-utils.test.js index 44275ff..dd4d199 100644 --- a/tests/hotmail-utils.test.js +++ b/tests/hotmail-utils.test.js @@ -194,6 +194,15 @@ test('extractVerificationCode supports runtime mail rule patterns', () => { ); }); +test('extractVerificationCode keeps capture groups when runtime patterns use global flags', () => { + assert.equal( + extractVerificationCode('验证码 248680', { + codePatterns: [{ source: '(?:验证码)[::\\s]*(\\d{6})', flags: 'gi' }], + }), + '248680' + ); +}); + test('extractVerificationCodeFromMessage reads code from the latest message subject or preview', () => { assert.equal( extractVerificationCodeFromMessage({ @@ -389,6 +398,30 @@ test('pickVerificationMessageWithTimeFallback can ignore afterTimestamp while ke assert.equal(result.usedTimeFallback, true); }); +test('pickVerificationMessageWithTimeFallback preserves runtime code patterns when only time is relaxed', () => { + const messages = [ + { + id: 'old-custom-code-mail', + subject: 'AWS Builder ID verification', + from: { emailAddress: { address: 'no-reply@signin.aws' } }, + bodyPreview: 'Use token A661122B to continue.', + receivedDateTime: '2026-05-22T09:41:00.000Z', + }, + ]; + + const result = pickVerificationMessageWithTimeFallback(messages, { + afterTimestamp: Date.UTC(2026, 4, 22, 9, 42, 0), + senderFilters: ['signin.aws'], + subjectFilters: ['aws'], + codePatterns: [{ source: 'token\\s+A(\\d{6})B', flags: 'i' }], + excludeCodes: [], + }); + + assert.equal(result.match.message.id, 'old-custom-code-mail'); + assert.equal(result.match.code, '661122'); + assert.equal(result.usedTimeFallback, true); +}); + test('buildHotmailMailApiLatestUrl includes email, client id, refresh token, and mailbox', () => { const url = new URL(buildHotmailMailApiLatestUrl({ apiUrl: 'https://example.com/api/mail-new',