Fix IMAP OTP extraction for Playwright auth

This commit is contained in:
A R R R Associates
2026-06-28 19:11:38 +05:30
parent db56738731
commit 9edba2375e
+153 -68
View File
@@ -1,5 +1,6 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
let ImapFlow; let ImapFlow;
try { try {
({ ImapFlow } = require('imapflow')); ({ ImapFlow } = require('imapflow'));
@@ -45,18 +46,30 @@ function readOtpFromFile() {
} }
function firstOtpMatch(text) { function firstOtpMatch(text) {
const pattern = process.env.IMAP_OTP_REGEX || '\\b\\d{6}\\b'; 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 flags = process.env.IMAP_OTP_REGEX_FLAGS || 'm';
const regex = new RegExp(pattern, flags); const regex = new RegExp(process.env.IMAP_OTP_REGEX, flags);
const match = String(text || '').match(regex); const match = value.match(regex);
if (!match) return ''; if (match) return match[1] || match[0];
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='') { function imapConfigForRole(roleText='', email='') {
const key = roleKey(roleText); const key = roleKey(roleText);
const user = process.env[`${key}_IMAP_USER`] || email || process.env.IMAP_USER; const user = process.env[`${key}_IMAP_USER`] || email || process.env.IMAP_USER;
const password = process.env[`${key}_IMAP_PASSWORD`] || process.env.IMAP_PASSWORD; const password = process.env[`${key}_IMAP_PASSWORD`] || process.env.IMAP_PASSWORD;
return { return {
host: process.env.IMAP_HOST, host: process.env.IMAP_HOST,
port: Number(process.env.IMAP_PORT || 993), port: Number(process.env.IMAP_PORT || 993),
@@ -78,6 +91,7 @@ async function fetchOtpOnceFromImap(config, sinceDate) {
if (!ImapFlow) { if (!ImapFlow) {
throw new Error('OTP_FROM_IMAP=true but package "imapflow" is not installed. Run: npm install'); 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) { 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.'); 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(); await client.connect();
try { try {
const mailboxList = []; const expectedFrom = String(
const primaryMailbox = config.mailbox || 'INBOX'; 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']) { const seen = new Set();
if (!mailboxList.includes(extraMailbox)) {
mailboxList.push(extraMailbox); for (const mailbox of mailboxes) {
} if (seen.has(mailbox)) continue;
} seen.add(mailbox);
for (const mailbox of mailboxList) {
let lock; let lock;
try { try {
lock = await client.getMailboxLock(mailbox); lock = await client.getMailboxLock(mailbox);
const query = { since: sinceDate }; const matches = [];
if (config.searchFrom) query.from = config.searchFrom;
let recentUids = []; for await (const msg of client.fetch(
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(
'1:*', '1:*',
{ envelope: true, source: true, uid: true }, { envelope: true, source: true, uid: true },
{ uid: false } { 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) => { if (
const ad = a.envelope?.date ? new Date(a.envelope.date).getTime() : 0; expectedFrom &&
const bd = b.envelope?.date ? new Date(b.envelope.date).getTime() : 0; !from.includes(expectedFrom) &&
return bd - ad; !fullText.toLowerCase().includes(expectedFrom)
}); ) {
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())) {
continue; continue;
} }
const otp = firstOtpMatch(fullText); 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. // Try next mailbox.
} finally { } finally {
if (lock) lock.release(); if (lock) lock.release();
@@ -172,7 +183,9 @@ async function fetchOtpOnceFromImap(config, sinceDate) {
return ''; return '';
} finally { } 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 startedAt = Date.now();
const sinceDate = new Date(startedAt - config.lookbackMinutes * 60 * 1000); const sinceDate = new Date(startedAt - config.lookbackMinutes * 60 * 1000);
const deadline = startedAt + config.waitSeconds * 1000; const deadline = startedAt + config.waitSeconds * 1000;
let lastError; let lastError;
while (Date.now() <= deadline) { while (Date.now() <= deadline) {
@@ -198,10 +212,14 @@ async function getOtp(roleText='', email='') {
lastError = error; lastError = error;
if (envBool('IMAP_FAIL_FAST', false)) throw error; if (envBool('IMAP_FAIL_FAST', false)) throw error;
} }
await sleep(3000); await sleep(3000);
} }
if (lastError && envBool('IMAP_THROW_ON_TIMEOUT', false)) throw lastError; if (lastError && envBool('IMAP_THROW_ON_TIMEOUT', false)) {
throw lastError;
}
return ''; return '';
} }
@@ -209,9 +227,13 @@ async function fillFirst(page, selectors, value) {
for (const sel of selectors) { for (const sel of selectors) {
const loc = page.locator(sel).first(); const loc = page.locator(sel).first();
if (await loc.count()) { if (await loc.count()) {
try { await loc.fill(value); return true; } catch (_) {} try {
await loc.fill(value);
return true;
} catch (_) {}
} }
} }
return false; return false;
} }
@@ -219,31 +241,94 @@ async function clickFirst(page, selectors) {
for (const sel of selectors) { for (const sel of selectors) {
const loc = page.locator(sel).first(); const loc = page.locator(sel).first();
if (await loc.count()) { if (await loc.count()) {
try { await loc.click(); return true; } catch (_) {} try {
await loc.click();
return true;
} catch (_) {}
} }
} }
return false; return false;
} }
async function login(page, roleText='System Admin') { async function login(page, roleText='System Admin') {
const [email, password] = roleCredentials(roleText); const [email, password] = roleCredentials(roleText);
await page.goto('/login'); 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 fillFirst(
await clickFirst(page, ['button[type="submit"]','input[type="submit"]','text=/login/i']); 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'); await page.waitForLoadState('domcontentloaded');
if (page.url().includes('/otp')) { if (page.url().includes('/otp')) {
const otp = await getOtp(roleText, email); const otp = await getOtp(roleText, email);
if (otp) { if (otp) {
await fillFirst(page, ['input[name="otp"]','input[name="code"]','input[type="text"]'], otp); await fillFirst(
await clickFirst(page, ['button[type="submit"]','input[type="submit"]','text=/verify/i']); 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'); await page.waitForLoadState('domcontentloaded');
} }
} }
} }
async function logout(page) { 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 }; module.exports = {
login,
logout,
roleCredentials,
roleKey,
getOtp,
fillFirst,
clickFirst,
};