/** * Email OTP retrieval — supports 3 providers: * mailslurp — managed SaaS (existing, no self-hosting) * imap — any real IMAP mailbox (Gmail, Outlook, custom domain catch-all) * mailpit — self-hosted SMTP trap (dev/staging only) * * Use mailslurp or imap for production apps. * Use mailpit only when you can point the app's SMTP at the trap server. */ export type EmailOTPProvider = 'mailslurp' | 'imap' | 'mailpit'; export interface EmailContent { /** 4-8 digit OTP code, if found. */ otp?: string; /** Magic link URL to navigate to, if found. */ magicLink?: string; } export interface EmailOTPConfig { provider: EmailOTPProvider; // MailSlurp apiKey?: string; inboxId?: string; // IMAP (Gmail app-password, Outlook, custom IMAP) imapHost?: string; imapPort?: number; imapUser?: string; imapPass?: string; imapTls?: boolean; // Mailpit (REST API at baseUrl) mailpitBaseUrl?: string; } const POLL_INTERVAL_MS = 2_000; const MAX_WAIT_MS = 60_000; /** Extract OTP code from email body text. Prefers 6-digit, falls back to 4-8 digit. */ function extractOTP(text: string): string | null { const m6 = text.match(/\b(\d{6})\b/); if (m6) return m6[1]; const m48 = text.match(/\b(\d{4,8})\b/); if (m48) return m48[1]; const magic = text.match(/\b([A-Z0-9]{6,10})\b/); if (magic) return magic[1]; return null; } /** Extract a magic-link URL from email body text. Matches /verify, /magic, /auth/callback, ?token= etc. */ function extractMagicLink(text: string): string | null { const magicPath = /\/(verify|magic|login|auth|confirm|sign-in)[/?#]/i; const magicQuery = /[?&](token|magic_token|login_token|auth_token|key)=/i; const urlMatches = text.match(/https:\/\/[^\s<>"')\]]+/g); if (!urlMatches) return null; for (const raw of urlMatches) { try { const u = new URL(raw.replace(/[>'")\].,]+$/, '')); if (magicPath.test(u.pathname) || magicQuery.test(u.search)) return u.toString(); } catch { /* skip malformed URLs */ } } return null; } function parseEmailContent(text: string): EmailContent { const magicLink = extractMagicLink(text); if (magicLink) return { magicLink }; const otp = extractOTP(text); if (otp) return { otp }; return {}; } // ── MailSlurp ──────────────────────────────────────────────────────────────── const MAILSLURP_BASE = 'https://api.mailslurp.com'; async function waitForContentMailSlurp(apiKey: string, inboxId: string, since: string): Promise { const deadline = Date.now() + MAX_WAIT_MS; while (Date.now() < deadline) { await new Promise(r => setTimeout(r, POLL_INTERVAL_MS)); try { const listRes = await fetch( `${MAILSLURP_BASE}/inboxes/${inboxId}/emails?sort=DESC&limit=5&since=${encodeURIComponent(since)}`, { headers: { 'x-api-key': apiKey } } ); if (!listRes.ok) continue; const emails = await listRes.json() as Array<{ id: string }>; if (!emails.length) continue; const bodyRes = await fetch(`${MAILSLURP_BASE}/emails/${emails[0].id}`, { headers: { 'x-api-key': apiKey } }); if (!bodyRes.ok) continue; const email = await bodyRes.json() as { body?: string; subject?: string }; const content = parseEmailContent((email.body ?? '') + ' ' + (email.subject ?? '')); if (content.otp || content.magicLink) return content; } catch { /* retry */ } } return null; } async function getInboxEmailMailSlurp(apiKey: string, inboxId: string): Promise { try { const res = await fetch(`${MAILSLURP_BASE}/inboxes/${inboxId}`, { headers: { 'x-api-key': apiKey } }); if (!res.ok) return null; const data = await res.json() as { emailAddress?: string }; return data.emailAddress ?? null; } catch { return null; } } // ── Mailpit (SMTP trap REST API) ────────────────────────────────────────────── async function waitForContentMailpit(baseUrl: string, since: Date): Promise { const deadline = Date.now() + MAX_WAIT_MS; const sinceMs = since.getTime(); while (Date.now() < deadline) { await new Promise(r => setTimeout(r, POLL_INTERVAL_MS)); try { const res = await fetch(`${baseUrl}/api/v1/messages?limit=10`); if (!res.ok) continue; const data = await res.json() as { messages?: Array<{ ID: string; Created: string; Snippet: string; Subject: string }> }; const recent = (data.messages ?? []).filter(m => new Date(m.Created).getTime() > sinceMs); for (const msg of recent) { const snippet = parseEmailContent(msg.Snippet + ' ' + msg.Subject); if (snippet.otp || snippet.magicLink) return snippet; // Fetch full body — snippet may truncate the magic link const bodyRes = await fetch(`${baseUrl}/api/v1/message/${msg.ID}`); if (!bodyRes.ok) continue; const body = await bodyRes.json() as { Text?: { Body?: string }; Subject?: string }; const full = parseEmailContent((body.Text?.Body ?? '') + ' ' + (body.Subject ?? '')); if (full.otp || full.magicLink) return full; } } catch { /* retry */ } } return null; } // ── IMAP (any mailbox) ──────────────────────────────────────────────────────── async function waitForContentImap( cfg: { host: string; port: number; user: string; pass: string; tls: boolean }, since: Date ): Promise { let ImapFlow: any; try { const mod = await import('imapflow'); ImapFlow = mod.ImapFlow; } catch { throw new Error('IMAP provider requires imapflow package. Add it to dependencies.'); } const client = new ImapFlow({ host: cfg.host, port: cfg.port, secure: cfg.tls, auth: { user: cfg.user, pass: cfg.pass }, logger: false, }); try { await client.connect(); const lock = await client.getMailboxLock('INBOX'); try { const deadline = Date.now() + MAX_WAIT_MS; while (Date.now() < deadline) { const uids = await client.search({ seen: false, since }); if (uids.length) { for await (const msg of client.fetch(uids.slice(-5), { source: true })) { const { simpleParser } = await import('mailparser'); const parsed = await simpleParser(msg.source); const text = (parsed.text ?? '') + ' ' + (parsed.subject ?? ''); const content = parseEmailContent(text); if (content.otp || content.magicLink) return content; } } await new Promise(r => setTimeout(r, POLL_INTERVAL_MS)); } return null; } finally { lock.release(); } } finally { await client.logout().catch(() => {}); } } // ── Public API ──────────────────────────────────────────────────────────────── /** * Wait for an email and return its OTP code or magic link URL. * Prefer this over waitForEmailOTP when the app may send passwordless magic links. */ export async function waitForEmailContent( config: EmailOTPConfig, since: string | Date ): Promise { const sinceStr = typeof since === 'string' ? since : since.toISOString(); const sinceDate = typeof since === 'string' ? new Date(since) : since; switch (config.provider) { case 'mailslurp': if (!config.apiKey || !config.inboxId) throw new Error('mailslurp requires apiKey + inboxId'); return waitForContentMailSlurp(config.apiKey, config.inboxId, sinceStr); case 'mailpit': return waitForContentMailpit(config.mailpitBaseUrl ?? 'http://mailpit:8025', sinceDate); case 'imap': if (!config.imapHost || !config.imapUser || !config.imapPass) { throw new Error('imap requires imapHost, imapUser, imapPass'); } return waitForContentImap({ host: config.imapHost, port: config.imapPort ?? 993, user: config.imapUser, pass: config.imapPass, tls: config.imapTls !== false, }, sinceDate); default: return null; } } /** * Wait for an OTP email and extract the code. * @deprecated Use waitForEmailContent which also handles magic links. */ export async function waitForEmailOTP( config: EmailOTPConfig, since: string | Date ): Promise { const content = await waitForEmailContent(config, since); return content?.otp ?? null; } /** * Get the email address for a MailSlurp inbox (used to display in UI). * Returns null for IMAP/Mailpit providers — use imapUser directly. */ export async function getInboxEmail( configOrApiKey: EmailOTPConfig | string, inboxId?: string ): Promise { // Legacy call: getInboxEmail(apiKey, inboxId) if (typeof configOrApiKey === 'string') { return getInboxEmailMailSlurp(configOrApiKey, inboxId ?? ''); } if (configOrApiKey.provider === 'mailslurp' && configOrApiKey.apiKey && configOrApiKey.inboxId) { return getInboxEmailMailSlurp(configOrApiKey.apiKey, configOrApiKey.inboxId); } if (configOrApiKey.provider === 'imap') { return configOrApiKey.imapUser ?? null; } return null; }