diff --git a/fixtures/auth.js b/fixtures/auth.js index e2d70ee..1677ec0 100644 --- a/fixtures/auth.js +++ b/fixtures/auth.js @@ -1,5 +1,6 @@ const fs = require('fs'); const path = require('path'); + let ImapFlow; try { ({ ImapFlow } = require('imapflow')); @@ -45,18 +46,30 @@ function readOtpFromFile() { } function firstOtpMatch(text) { - const pattern = process.env.IMAP_OTP_REGEX || '\\b\\d{6}\\b'; - const flags = process.env.IMAP_OTP_REGEX_FLAGS || 'm'; - const regex = new RegExp(pattern, flags); - const match = String(text || '').match(regex); - if (!match) return ''; - return match[1] || match[0]; + const value = String(text || ''); + + // First try custom regex from environment, if provided. + if (process.env.IMAP_OTP_REGEX) { + try { + const flags = process.env.IMAP_OTP_REGEX_FLAGS || 'm'; + const regex = new RegExp(process.env.IMAP_OTP_REGEX, flags); + const match = value.match(regex); + if (match) return match[1] || match[0]; + } catch (_) { + // Fall back to default 6-digit OTP regex. + } + } + + // Reliable fallback for ERP OTP mails. + const fallback = value.match(/\b\d{6}\b/); + return fallback ? fallback[0] : ''; } function imapConfigForRole(roleText='', email='') { const key = roleKey(roleText); const user = process.env[`${key}_IMAP_USER`] || email || process.env.IMAP_USER; const password = process.env[`${key}_IMAP_PASSWORD`] || process.env.IMAP_PASSWORD; + return { host: process.env.IMAP_HOST, port: Number(process.env.IMAP_PORT || 993), @@ -78,6 +91,7 @@ async function fetchOtpOnceFromImap(config, sinceDate) { if (!ImapFlow) { throw new Error('OTP_FROM_IMAP=true but package "imapflow" is not installed. Run: npm install'); } + if (!config.host || !config.auth.user || !config.auth.pass) { throw new Error('Missing IMAP_HOST, role IMAP user, or role IMAP password for OTP fetch.'); } @@ -93,77 +107,74 @@ async function fetchOtpOnceFromImap(config, sinceDate) { await client.connect(); try { - const mailboxList = []; - const primaryMailbox = config.mailbox || 'INBOX'; + const expectedFrom = String( + config.searchFrom || process.env.IMAP_SEARCH_FROM || 'no-reply@vavalam.com' + ).trim().toLowerCase(); - mailboxList.push(primaryMailbox); + const mailboxes = [ + config.mailbox || 'INBOX', + 'Junk Mail', + 'Deleted Items', + ]; - for (const extraMailbox of ['INBOX', 'Junk Mail', 'Deleted Items']) { - if (!mailboxList.includes(extraMailbox)) { - mailboxList.push(extraMailbox); - } - } + const seen = new Set(); + + for (const mailbox of mailboxes) { + if (seen.has(mailbox)) continue; + seen.add(mailbox); - for (const mailbox of mailboxList) { let lock; try { lock = await client.getMailboxLock(mailbox); - const query = { since: sinceDate }; - if (config.searchFrom) query.from = config.searchFrom; + const matches = []; - let recentUids = []; - - try { - const uids = await client.search(query, { uid: true }); - recentUids = (uids || []).slice(-config.maxMessages).reverse(); - } catch (_) { - recentUids = []; - } - - if (recentUids.length) { - for await (const message of client.fetch( - recentUids.join(','), - { envelope: true, source: true }, - { uid: true } - )) { - const envelopeText = JSON.stringify(message.envelope || {}); - const sourceText = message.source ? message.source.toString('utf8') : ''; - const otp = firstOtpMatch(`${envelopeText}\n${sourceText}`); - if (otp) return otp; - } - } - - const allMessages = []; - - for await (const message of client.fetch( + for await (const msg of client.fetch( '1:*', { envelope: true, source: true, uid: true }, { uid: false } )) { - allMessages.push(message); - } + const from = msg.envelope?.from?.map(x => x.address).join(', ').toLowerCase() || ''; + const subject = msg.envelope?.subject || ''; + const sourceText = msg.source ? msg.source.toString('utf8') : ''; + const envelopeText = JSON.stringify(msg.envelope || {}); + const fullText = envelopeText + '\n' + sourceText; - allMessages.sort((a, b) => { - const ad = a.envelope?.date ? new Date(a.envelope.date).getTime() : 0; - const bd = b.envelope?.date ? new Date(b.envelope.date).getTime() : 0; - return bd - ad; - }); - - for (const message of allMessages.slice(0, config.maxMessages || 20)) { - const envelopeText = JSON.stringify(message.envelope || {}); - const sourceText = message.source ? message.source.toString('utf8') : ''; - const fullText = `${envelopeText}\n${sourceText}`; - - if (config.searchFrom && !fullText.toLowerCase().includes(String(config.searchFrom).toLowerCase())) { + if ( + expectedFrom && + !from.includes(expectedFrom) && + !fullText.toLowerCase().includes(expectedFrom) + ) { continue; } const otp = firstOtpMatch(fullText); - if (otp) return otp; + if (!otp) continue; + + matches.push({ + uid: msg.uid || 0, + date: msg.envelope?.date ? new Date(msg.envelope.date).getTime() : 0, + otp, + subject, + from, + }); + } + + matches.sort((a, b) => { + if ((b.date || 0) !== (a.date || 0)) { + return (b.date || 0) - (a.date || 0); + } + return (b.uid || 0) - (a.uid || 0); + }); + + if (matches[0]?.otp) { + return matches[0].otp; + } + } catch (error) { + if (envBool('IMAP_FAIL_FAST', false)) { + throw error; } - } catch (_) { // Try next mailbox. } finally { if (lock) lock.release(); @@ -172,7 +183,9 @@ async function fetchOtpOnceFromImap(config, sinceDate) { return ''; } finally { - try { await client.logout(); } catch (_) {} + try { + await client.logout(); + } catch (_) {} } } @@ -188,6 +201,7 @@ async function getOtp(roleText='', email='') { const startedAt = Date.now(); const sinceDate = new Date(startedAt - config.lookbackMinutes * 60 * 1000); const deadline = startedAt + config.waitSeconds * 1000; + let lastError; while (Date.now() <= deadline) { @@ -198,10 +212,14 @@ async function getOtp(roleText='', email='') { lastError = error; if (envBool('IMAP_FAIL_FAST', false)) throw error; } + await sleep(3000); } - if (lastError && envBool('IMAP_THROW_ON_TIMEOUT', false)) throw lastError; + if (lastError && envBool('IMAP_THROW_ON_TIMEOUT', false)) { + throw lastError; + } + return ''; } @@ -209,9 +227,13 @@ async function fillFirst(page, selectors, value) { for (const sel of selectors) { const loc = page.locator(sel).first(); if (await loc.count()) { - try { await loc.fill(value); return true; } catch (_) {} + try { + await loc.fill(value); + return true; + } catch (_) {} } } + return false; } @@ -219,31 +241,94 @@ async function clickFirst(page, selectors) { for (const sel of selectors) { const loc = page.locator(sel).first(); if (await loc.count()) { - try { await loc.click(); return true; } catch (_) {} + try { + await loc.click(); + return true; + } catch (_) {} } } + return false; } async function login(page, roleText='System Admin') { const [email, password] = roleCredentials(roleText); + await page.goto('/login'); - await fillFirst(page, ['input[name="email"]','input[name="login_id"]','input[type="email"]','input[name="username"]','input[name="user"]'], email || ''); - await fillFirst(page, ['input[name="password"]','input[type="password"]'], password || ''); - await clickFirst(page, ['button[type="submit"]','input[type="submit"]','text=/login/i']); + + await fillFirst( + page, + [ + 'input[name="email"]', + 'input[name="login_id"]', + 'input[type="email"]', + 'input[name="username"]', + 'input[name="user"]', + ], + email || '' + ); + + await fillFirst( + page, + [ + 'input[name="password"]', + 'input[type="password"]', + ], + password || '' + ); + + await clickFirst( + page, + [ + 'button[type="submit"]', + 'input[type="submit"]', + 'text=/login/i', + ] + ); + await page.waitForLoadState('domcontentloaded'); + if (page.url().includes('/otp')) { const otp = await getOtp(roleText, email); + if (otp) { - await fillFirst(page, ['input[name="otp"]','input[name="code"]','input[type="text"]'], otp); - await clickFirst(page, ['button[type="submit"]','input[type="submit"]','text=/verify/i']); + await fillFirst( + page, + [ + 'input[name="otp"]', + 'input[name="code"]', + 'input[type="text"]', + ], + otp + ); + + await clickFirst( + page, + [ + 'button[type="submit"]', + 'input[type="submit"]', + 'text=/verify/i', + ] + ); + await page.waitForLoadState('domcontentloaded'); } } } async function logout(page) { - try { await page.goto('/logout'); await page.waitForLoadState('domcontentloaded'); } catch (_) {} + try { + await page.goto('/logout'); + await page.waitForLoadState('domcontentloaded'); + } catch (_) {} } -module.exports = { login, logout, roleCredentials, roleKey, getOtp, fillFirst, clickFirst }; \ No newline at end of file +module.exports = { + login, + logout, + roleCredentials, + roleKey, + getOtp, + fillFirst, + clickFirst, +}; \ No newline at end of file