Skip to content

Commit 979b5cf

Browse files
authored
fix(e2e): switch temp-email provider from mail.tm to Guerrilla Mail (#303)
1 parent 05c1505 commit 979b5cf

2 files changed

Lines changed: 267 additions & 115 deletions

File tree

e2e/helpers/temp-email-mailtm.ts

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
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

Comments
 (0)