Source: utils/disposableEmail.js

/**
 * @module utils/disposableEmail
 * @description S3-08 — Disposable email support for auth flow testing.
 *
 * Sentri can discover and crawl auth flows but cannot complete registration
 * flows that require email verification. This module provides a
 * `DisposableEmail` client backed by the temp-mail.io API, and a
 * `fillEmailVerificationFlow` helper that `stateExplorer.js` calls when it
 * encounters a signup form.
 *
 * ### Flow
 * 1. `createMailbox()` — allocates a random disposable address.
 * 2. `fillSignupForm(page, mailbox, fields)` — fills the form with the
 *    temp address and any other generated test data.
 * 3. `waitForOtp(mailbox)` — polls the inbox until a message arrives,
 *    then extracts a numeric OTP or verification link.
 * 4. `fillOtpFields(page, otp)` — handles both single-input and
 *    split-digit OTP inputs, using clipboard paste for split fields.
 * 5. `dispose(mailbox)` — deletes the mailbox (best-effort cleanup).
 *
 * ### Configuration
 * | Env var                     | Default                          | Description           |
 * |-----------------------------|----------------------------------|-----------------------|
 * | `TEMP_MAIL_API_KEY`         | —                                | temp-mail.io API key  |
 * | `TEMP_MAIL_BASE_URL`        | https://api.temp-mail.io/request | API base URL          |
 * | `DISPOSABLE_EMAIL_TIMEOUT`  | 60000                            | OTP poll timeout (ms) |
 * | `DISPOSABLE_EMAIL_POLL_MS`  | 3000                             | Poll interval (ms)    |
 *
 * ### Exports
 * - {@link createMailbox}             — allocate a temp email address
 * - {@link waitForOtp}                — poll inbox and extract OTP/link
 * - {@link fillOtpFields}             — fill OTP into page (single or split)
 * - {@link fillEmailVerificationFlow} — high-level helper for stateExplorer
 * - {@link dispose}                   — delete mailbox (cleanup)
 */

const BASE_URL    = process.env.TEMP_MAIL_BASE_URL   || "https://api.temp-mail.io/request";
const API_KEY     = process.env.TEMP_MAIL_API_KEY    || "";
const OTP_TIMEOUT = parseInt(process.env.DISPOSABLE_EMAIL_TIMEOUT, 10) || 60_000;
const POLL_MS     = parseInt(process.env.DISPOSABLE_EMAIL_POLL_MS,  10) || 3_000;

// ── Test data for non-email signup fields ────────────────────────────────────
// Mirrors the hint-based logic in actionDiscovery.js generateTestData() so that
// password, name, and other required fields are filled during the signup flow.
const SIGNUP_TEST_PASSWORD = "SentriTest123!";
const SIGNUP_TEST_DATA = {
  password: SIGNUP_TEST_PASSWORD,
  name:     "Jane Doe",
  first:    "Jane",
  last:     "Doe",
  username: "sentri-tester",
  phone:    "+1234567890",
  company:  "Sentri Corp",
};

/**
 * Pick a test value for a non-email signup field based on its type and hints.
 * Returns null if no match is found (field will be skipped).
 */
function pickSignupFieldValue(fieldType, hints) {
  if (fieldType === "password" || hints.includes("password")) return SIGNUP_TEST_DATA.password;
  if (hints.includes("first name") || hints.includes("firstname")) return SIGNUP_TEST_DATA.first;
  if (hints.includes("last name") || hints.includes("lastname")) return SIGNUP_TEST_DATA.last;
  if (hints.includes("username") || hints.includes("user name")) return SIGNUP_TEST_DATA.username;
  if (hints.includes("name") || hints.includes("full name")) return SIGNUP_TEST_DATA.name;
  if (fieldType === "tel" || hints.includes("phone")) return SIGNUP_TEST_DATA.phone;
  if (hints.includes("company") || hints.includes("organization")) return SIGNUP_TEST_DATA.company;
  // Generic text fallback for any other required field
  if (fieldType === "text") return "Sentri test input";
  return null;
}

// ── Regex patterns for OTP / verification link extraction ────────────────────

/** Matches 4–8 digit numeric OTPs (most common format) */
const OTP_DIGIT_RE = /\b(\d{4,8})\b/;

/** Matches verification/activation/confirm links */
const VERIFY_LINK_RE = /https?:\/\/[^\s"'<>]+(?:verify|confirm|activate|token|code)[^\s"'<>]*/i;

// ── Low-level API client ─────────────────────────────────────────────────────

/**
 * Build common fetch headers, including the API key if configured.
 * @returns {Record<string, string>}
 */
function apiHeaders() {
  const h = { "Content-Type": "application/json" };
  if (API_KEY) h["Authorization"] = `Bearer ${API_KEY}`;
  return h;
}

/**
 * Fetch a JSON endpoint, returning the parsed body or throwing on HTTP errors.
 * @param {string} url
 * @param {RequestInit} [opts]
 * @returns {Promise<unknown>}
 */
async function apiFetch(url, opts = {}) {
  const res = await fetch(url, { headers: apiHeaders(), ...opts });
  if (!res.ok) {
    const body = await res.text().catch(() => "");
    throw new Error(`DisposableEmail API error ${res.status}: ${body.slice(0, 200)}`);
  }
  return res.json();
}

// ── Mailbox lifecycle ─────────────────────────────────────────────────────────

/**
 * @typedef {object} Mailbox
 * @property {string} address   - The full email address (e.g. abc123@mailnull.com)
 * @property {string} id        - Mailbox identifier used for subsequent API calls
 * @property {string} _token    - Internal token for message retrieval
 */

/**
 * createMailbox() → Mailbox
 *
 * Allocates a new disposable email address via the temp-mail.io API.
 * The returned `Mailbox` object is passed to all other functions in this module.
 *
 * @returns {Promise<Mailbox>}
 */
export async function createMailbox() {
  let data;
  try {
    data = await apiFetch(`${BASE_URL}/new/`, { method: "POST", body: "{}" });
  } catch (err) {
    // If no API key is configured or the service is unavailable, fall back to
    // a locally generated address that looks valid enough to fill forms.
    // OTP retrieval will fail gracefully — the caller handles this case.
    const fallback = `sentri-${Date.now()}-${Math.random().toString(36).slice(2, 7)}@mailnull.com`;
    console.warn(`[disposableEmail] createMailbox fallback (${err.message}): ${fallback}`);
    return { address: fallback, id: fallback, _token: "" };
  }

  return {
    address: data.address || data.email,
    id:      data.id || data.address || data.email,
    _token:  data.token || data.id || "",
  };
}

/**
 * dispose(mailbox) → void
 *
 * Deletes the mailbox. Best-effort — never throws.
 *
 * @param {Mailbox} mailbox
 * @returns {Promise<void>}
 */
export async function dispose(mailbox) {
  if (!mailbox?.id) return;
  try {
    await apiFetch(`${BASE_URL}/delete/id/${encodeURIComponent(mailbox.id)}/`, { method: "DELETE" });
  } catch { /* best-effort — ignore */ }
}

// ── OTP extraction ────────────────────────────────────────────────────────────

/**
 * extractOtpFromMessage(message) → { otp: string|null, link: string|null }
 *
 * Tries to extract a numeric OTP code or a verification URL from a raw email
 * message body. Returns both in case the caller wants to try the link first.
 *
 * @param {string} text  - Plain-text or HTML email body
 * @returns {{ otp: string|null, link: string|null }}
 */
export function extractOtpFromMessage(text) {
  // Strip HTML tags for cleaner matching
  const plain = text.replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ");

  const linkMatch = plain.match(VERIFY_LINK_RE);
  const otpMatch  = plain.match(OTP_DIGIT_RE);

  return {
    otp:  otpMatch  ? otpMatch[1]  : null,
    link: linkMatch ? linkMatch[0] : null,
  };
}

/**
 * waitForOtp(mailbox) → { otp: string|null, link: string|null }
 *
 * Polls the temp-mail.io inbox until a message arrives or the timeout is
 * reached. On success, extracts an OTP code or verification link.
 *
 * @param {Mailbox} mailbox
 * @returns {Promise<{ otp: string|null, link: string|null }>}
 */
export async function waitForOtp(mailbox) {
  if (!mailbox?.id || !API_KEY) {
    // No API key — caller should skip OTP verification step
    return { otp: null, link: null };
  }

  const deadline = Date.now() + OTP_TIMEOUT;

  while (Date.now() < deadline) {
    await new Promise(r => setTimeout(r, POLL_MS));

    let messages;
    try {
      messages = await apiFetch(`${BASE_URL}/mail/id/${encodeURIComponent(mailbox.id)}/`);
    } catch {
      continue; // network hiccup — keep polling
    }

    const list = Array.isArray(messages) ? messages : (messages?.mails || []);
    if (list.length === 0) continue;

    // Use the most recent message
    const latest = list[list.length - 1];
    const body   = latest.body_text || latest.body_html || latest.textBody || latest.htmlBody || "";
    const subject = latest.subject || "";

    const result = extractOtpFromMessage(body + " " + subject);
    if (result.otp || result.link) return result;
  }

  return { otp: null, link: null };
}

// ── Page interaction helpers ──────────────────────────────────────────────────

/**
 * fillOtpFields(page, otp) → boolean
 *
 * Fills an OTP code into the active page, handling both:
 *   - Single `<input>` fields (fills the whole string)
 *   - Split digit inputs (one `<input maxlength="1">` per digit, pasted
 *     via clipboard so focus advances automatically)
 *
 * Returns `true` if at least one input was filled successfully.
 *
 * @param {Object} page  - Playwright Page instance
 * @param {string} otp
 * @returns {Promise<boolean>}
 */
export async function fillOtpFields(page, otp) {
  if (!otp) return false;

  // Detect split OTP inputs (multiple single-character inputs in a row)
  const splitInputs = await page.locator("input[maxlength='1']").all();
  if (splitInputs.length >= otp.length) {
    // Paste via clipboard for maximum compatibility with SPA OTP components
    // (React, Vue, Svelte OTP inputs rely on the paste event to advance focus)
    try {
      await page.evaluate(async (code) => {
        await navigator.clipboard.writeText(code);
      }, otp).catch(() => {});

      // Focus the first input and dispatch a paste event
      await splitInputs[0].focus().catch(() => {});
      await page.keyboard.press("Control+V").catch(() => {});
      await page.waitForTimeout(300);

      // Fallback: fill each digit individually if paste didn't populate them
      const values = await Promise.all(splitInputs.slice(0, otp.length).map(el => el.inputValue().catch(() => "")));
      const alreadyFilled = values.some(v => v.length > 0);
      if (!alreadyFilled) {
        for (let i = 0; i < Math.min(otp.length, splitInputs.length); i++) {
          await splitInputs[i].fill(otp[i]).catch(() => {});
        }
      }
      return true;
    } catch { return false; }
  }

  // Single OTP input — try common selectors
  const singleSelectors = [
    "input[autocomplete='one-time-code']",
    "input[name*='otp' i]",
    "input[name*='code' i]",
    "input[name*='token' i]",
    "input[placeholder*='code' i]",
    "input[placeholder*='otp' i]",
    "input[aria-label*='code' i]",
    "input[aria-label*='otp' i]",
  ];

  for (const sel of singleSelectors) {
    const el = page.locator(sel).first();
    try {
      const count = await el.count();
      if (count === 0) continue;
      await el.fill(otp);
      return true;
    } catch { continue; }
  }

  return false;
}

// ── High-level helper for stateExplorer ──────────────────────────────────────

/**
 * fillSignupFields(page, formFields, mailboxAddress) → void
 *
 * Fills all form fields for a signup form: the email field gets the
 * disposable address, and other fields (password, name, etc.) get
 * realistic test data via `pickSignupFieldValue`.
 *
 * @param {Object} page  - Playwright Page instance
 * @param {Array<Object>} formFields  - Field descriptors with selector, type, label, etc.
 * @param {string} mailboxAddress  - Disposable email address to use
 */
async function fillSignupFields(page, formFields, mailboxAddress) {
  for (const field of formFields) {
    const fieldType = (field.type || "").toLowerCase();
    const hints     = `${field.label || ""} ${field.placeholder || ""} ${field.ariaLabel || ""}`.toLowerCase();

    let value = null;
    if (fieldType === "email" || hints.includes("email")) {
      value = mailboxAddress;
    } else {
      value = pickSignupFieldValue(fieldType, hints);
    }

    if (value && field.selector) {
      try {
        const el = page.locator(field.selector).first();
        await el.fill(value);
      } catch { /* element may have shifted — skip */ }
    }
  }
}

/**
 * fillEmailVerificationFlow(page, formFields, run) → { email, mailbox, otpFilled, linkFollowed }
 *
 * High-level helper called by `stateExplorer.js` when it encounters a
 * signup/registration form. Orchestrates the full flow:
 *
 * 1. Creates a disposable mailbox.
 * 2. Fills ALL form fields — email with the temp address, password and
 *    other required fields with generated test data.
 * 3. Returns the mailbox so the caller can submit the form first, then
 *    call `waitForVerification` to poll for OTP/link.
 *
 * The caller is responsible for:
 *   - Submitting the form (clicking submit button) after this returns
 *   - Calling `waitForVerification(page, mailbox)` to poll for OTP/link
 *   - Calling `dispose(mailbox)` when done
 *
 * @param {Object} page  - Playwright Page instance
 * @param {Array<Object>} formFields  - Field descriptors with selector, type, label, etc.
 * @param {object} run  - Mutable run record for SSE logging
 * @returns {Promise<{ email: string, mailbox: object }>}
 */
export async function fillEmailVerificationFlow(page, formFields, run) {
  const mailbox = await createMailbox();

  // Fill all form fields — email with disposable address, others with test data
  await fillSignupFields(page, formFields, mailbox.address);

  return { email: mailbox.address, mailbox };
}

/**
 * waitForVerification(page, mailbox) → { otpFilled, linkFollowed }
 *
 * Polls the disposable mailbox for a verification OTP or link, then
 * fills the OTP or follows the link on the page. Should be called
 * AFTER the signup form has been submitted.
 *
 * @param {Object} page  - Playwright Page instance
 * @param {object} mailbox  - Mailbox object from fillEmailVerificationFlow
 * @returns {Promise<{ otpFilled: boolean, linkFollowed: boolean }>}
 */
export async function waitForVerification(page, mailbox) {
  let otpFilled    = false;
  let linkFollowed = false;

  const { otp, link } = await waitForOtp(mailbox);

  if (link) {
    try {
      await page.goto(link, { waitUntil: "domcontentloaded", timeout: 15_000 });
      linkFollowed = true;
    } catch { /* link may have expired */ }
  } else if (otp) {
    otpFilled = await fillOtpFields(page, otp);
  }

  return { otpFilled, linkFollowed };
}