Fix IMAP OTP extraction for Playwright auth
This commit is contained in:
+153
-68
@@ -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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user