|
| 1 | +/** |
| 2 | + * Temporary email service using the mail.tm/mail.gw API. |
| 3 | + * |
| 4 | + * Kept as an alternative to the default `temp-email.ts` (Guerrilla). mail.tm |
| 5 | + * reliably creates inboxes but silently drops a fraction of inbound mail, so |
| 6 | + * OTP emails often never arrive — that's why the suite switched to Guerrilla. |
| 7 | + * To use this one instead, point the tests' imports here. |
| 8 | + * |
| 9 | + * Both mail.tm and mail.gw endpoints serve the same backend; mail.gw has been |
| 10 | + * intermittently |
| 11 | + * unreachable (HTTP 502) so we default to mail.tm. Override via |
| 12 | + * `MAIL_API_BASE` if a different mirror is needed. |
| 13 | + * |
| 14 | + * Response handling tolerates both shapes the API has shipped: |
| 15 | + * - bare JSON arrays / objects (newer mail.gw responses) |
| 16 | + * - `hydra:member`-wrapped collections (older / mail.tm responses) |
| 17 | + */ |
| 18 | + |
| 19 | +const MAIL_API_BASE = process.env.MAIL_API_BASE || 'https://api.mail.tm' |
| 20 | + |
| 21 | +export type TempEmailAccount = { |
| 22 | + address: string |
| 23 | + authToken: string |
| 24 | +} |
| 25 | + |
| 26 | +const defaultHeaders = { |
| 27 | + Accept: 'application/json', |
| 28 | + 'Content-Type': 'application/json', |
| 29 | +} |
| 30 | + |
| 31 | +/** |
| 32 | + * Pings the email service to check availability. |
| 33 | + * @throws If the service is unavailable |
| 34 | + */ |
| 35 | +export async function ping(): Promise<void> { |
| 36 | + const res = await fetch(`${MAIL_API_BASE}/domains`, { |
| 37 | + headers: defaultHeaders, |
| 38 | + signal: AbortSignal.timeout(10_000), |
| 39 | + }) |
| 40 | + if (!res.ok) { |
| 41 | + throw new Error( |
| 42 | + `Email service unavailable: ${res.status} ${res.statusText}`, |
| 43 | + ) |
| 44 | + } |
| 45 | + const data = await res.json() |
| 46 | + // API returns a plain array of domain objects |
| 47 | + const domains = Array.isArray(data) ? data : (data['hydra:member'] ?? []) |
| 48 | + if (domains.length === 0) { |
| 49 | + throw new Error('Email service has no domains available') |
| 50 | + } |
| 51 | +} |
| 52 | + |
| 53 | +/** |
| 54 | + * Creates a new temporary email account. |
| 55 | + * Generates a random email address and returns it with an auth token. |
| 56 | + */ |
| 57 | +export async function createNewAccount(): Promise<TempEmailAccount> { |
| 58 | + // Get available domain |
| 59 | + const domainsRes = await fetch(`${MAIL_API_BASE}/domains`, { |
| 60 | + headers: defaultHeaders, |
| 61 | + signal: AbortSignal.timeout(10_000), |
| 62 | + }) |
| 63 | + if (!domainsRes.ok) { |
| 64 | + throw new Error(`Failed to get domains: ${domainsRes.status}`) |
| 65 | + } |
| 66 | + const domainsData = await domainsRes.json() |
| 67 | + |
| 68 | + // Handle both array response and hydra:member wrapped response |
| 69 | + const domains = Array.isArray(domainsData) |
| 70 | + ? domainsData |
| 71 | + : (domainsData['hydra:member'] ?? []) |
| 72 | + const domain = domains[0]?.domain |
| 73 | + if (!domain) { |
| 74 | + throw new Error('No email domains available') |
| 75 | + } |
| 76 | + |
| 77 | + // Generate random email |
| 78 | + const randomHex = Array.from(crypto.getRandomValues(new Uint8Array(8))) |
| 79 | + .map((b) => b.toString(16).padStart(2, '0')) |
| 80 | + .join('') |
| 81 | + const address = `email_${randomHex}@${domain}` |
| 82 | + const password = '123456' |
| 83 | + |
| 84 | + // Create account. mail.tm rate-limits account creation aggressively |
| 85 | + // (~3–5/min). On 429 we back off and retry; for any other failure we |
| 86 | + // fail loud immediately. |
| 87 | + const ACCOUNT_CREATE_ATTEMPTS = 5 |
| 88 | + let createRes: Response | undefined |
| 89 | + for (let attempt = 0; attempt < ACCOUNT_CREATE_ATTEMPTS; attempt++) { |
| 90 | + createRes = await fetch(`${MAIL_API_BASE}/accounts`, { |
| 91 | + method: 'POST', |
| 92 | + headers: defaultHeaders, |
| 93 | + body: JSON.stringify({ address, password }), |
| 94 | + }) |
| 95 | + if (createRes.ok) break |
| 96 | + if (createRes.status !== 429) { |
| 97 | + const text = await createRes.text() |
| 98 | + throw new Error( |
| 99 | + `Failed to create email account: ${createRes.status} ${text}`, |
| 100 | + ) |
| 101 | + } |
| 102 | + // Exponential backoff: 5s, 10s, 20s, 40s. mail.tm's window is short |
| 103 | + // enough that this almost always recovers within two retries. |
| 104 | + const delayMs = 5_000 * 2 ** attempt |
| 105 | + await new Promise((r) => setTimeout(r, delayMs)) |
| 106 | + } |
| 107 | + if (!createRes || !createRes.ok) { |
| 108 | + throw new Error( |
| 109 | + `Failed to create email account after ${ACCOUNT_CREATE_ATTEMPTS} attempts: ${createRes?.status ?? 'no response'}`, |
| 110 | + ) |
| 111 | + } |
| 112 | + |
| 113 | + // Get auth token |
| 114 | + const tokenRes = await fetch(`${MAIL_API_BASE}/token`, { |
| 115 | + method: 'POST', |
| 116 | + headers: defaultHeaders, |
| 117 | + body: JSON.stringify({ address, password }), |
| 118 | + }) |
| 119 | + if (!tokenRes.ok) { |
| 120 | + const text = await tokenRes.text() |
| 121 | + throw new Error(`Failed to get email token: ${tokenRes.status} ${text}`) |
| 122 | + } |
| 123 | + const tokenData = await tokenRes.json() |
| 124 | + const authToken = tokenData.token |
| 125 | + if (!authToken) { |
| 126 | + throw new Error('No token in response') |
| 127 | + } |
| 128 | + |
| 129 | + return { address, authToken } |
| 130 | +} |
| 131 | + |
| 132 | +/** |
| 133 | + * Polls for a new email and returns its text content. |
| 134 | + * |
| 135 | + * @param authToken - The auth token from createNewAccount |
| 136 | + * @param intervalMs - Poll interval in milliseconds |
| 137 | + * @param timeoutMs - Maximum time to wait in milliseconds |
| 138 | + * @returns The email text content |
| 139 | + * @throws If no email arrives within the timeout |
| 140 | + */ |
| 141 | +export async function searchForNewEmail( |
| 142 | + authToken: string, |
| 143 | + intervalMs: number, |
| 144 | + timeoutMs: number, |
| 145 | +): Promise<string> { |
| 146 | + const deadline = Date.now() + timeoutMs |
| 147 | + |
| 148 | + while (Date.now() < deadline) { |
| 149 | + await sleep(intervalMs) |
| 150 | + |
| 151 | + try { |
| 152 | + const messagesRes = await fetch(`${MAIL_API_BASE}/messages`, { |
| 153 | + headers: { |
| 154 | + ...defaultHeaders, |
| 155 | + Authorization: `Bearer ${authToken}`, |
| 156 | + }, |
| 157 | + signal: AbortSignal.timeout(10_000), |
| 158 | + }) |
| 159 | + |
| 160 | + if (!messagesRes.ok) { |
| 161 | + // 401 or 500 may mean the inbox isn't ready yet, keep polling |
| 162 | + if (messagesRes.status >= 500 || messagesRes.status === 401) continue |
| 163 | + throw new Error(`Failed to list messages: ${messagesRes.status}`) |
| 164 | + } |
| 165 | + |
| 166 | + const messagesData = await messagesRes.json() |
| 167 | + |
| 168 | + // Handle both array response and hydra:member wrapped response |
| 169 | + const messages = Array.isArray(messagesData) |
| 170 | + ? messagesData |
| 171 | + : (messagesData['hydra:member'] ?? []) |
| 172 | + if (messages.length === 0) continue |
| 173 | + |
| 174 | + // Get full message content |
| 175 | + const messageId = messages[0].id |
| 176 | + const messageRes = await fetch(`${MAIL_API_BASE}/messages/${messageId}`, { |
| 177 | + headers: { |
| 178 | + ...defaultHeaders, |
| 179 | + Authorization: `Bearer ${authToken}`, |
| 180 | + }, |
| 181 | + signal: AbortSignal.timeout(10_000), |
| 182 | + }) |
| 183 | + |
| 184 | + if (!messageRes.ok) { |
| 185 | + throw new Error(`Failed to get message: ${messageRes.status}`) |
| 186 | + } |
| 187 | + |
| 188 | + const messageData = await messageRes.json() |
| 189 | + const text = messageData.text |
| 190 | + if (text) return text |
| 191 | + } catch (err) { |
| 192 | + // Network errors during polling — keep trying |
| 193 | + if (Date.now() >= deadline) throw err |
| 194 | + } |
| 195 | + } |
| 196 | + |
| 197 | + throw new Error(`No email received within ${timeoutMs}ms timeout`) |
| 198 | +} |
| 199 | + |
| 200 | +function sleep(ms: number): Promise<void> { |
| 201 | + return new Promise((resolve) => setTimeout(resolve, ms)) |
| 202 | +} |
0 commit comments