Temp Mail Pentest
Create disposable inboxes on demand. Use the Guerrilla Mail API to receive real emails during active pentesting — registration confirmations, password resets, data leakage verification, and rate-limit testing. Each target gets its own JSON state file so evidence stays organized per engagement.
Reference implementation: This skill ships with a full installable CLI tool alongside this file (
tempmail/). Run it directly withuv run -p <skill-dir>/tempmail tempmail init --target example.comor install globally withuv tool install <skill-dir>/tempmail. The templates below teach an agent how to implement the same logic from scratch when more flexibility is needed.
When to Use
This skill activates when the user asks to:
- Register on a target site that requires email confirmation
- Receive and extract a password reset link or token
- Test whether sensitive data leaks in outgoing transactional emails
- Validate that an email-sending endpoint (contact form, invite, notification) actually fires
- Test rate limits or spam handling by receiving multiple emails to the same inbox
Do not use this skill for general email automation, email marketing, non-security email verification, or any task unrelated to authorized security testing.
API Overview
Base URL: http://api.guerrillamail.com/ajax.php
All requests require these parameters:
| Param | Value |
|---|---|
f |
Function name (see API Reference) |
ip |
Placeholder: "0.0.0.0" |
agent |
Placeholder: "Mozilla_pentest_agent" |
Session: The server sets a PHPSESSID cookie on first call. Capture it from the Set-Cookie response header and send it back as Cookie: PHPSESSID=<sid> on every subsequent call. The SID can change at any response — always check the response headers for a new one.
Note: The API uses plain HTTP. Some pentest environments may block it. If the API is unreachable, inform the user and suggest alternatives (e.g., a different temp-mail provider).
JSON State File
Every target gets its own state file in the current working directory:
inbox-<normalized_target>.json
The target name is derived from the engagement — prompt the user for a label or derive it from the target domain (e.g., inbox-target.com.json).
Schema:
{
"session": {
"email": "abc123@guerrillamailblock.com",
"sid": "phpsessid_value",
"created_at": 1717200000
},
"inbox": [
{
"id": 425,
"from": "noreply@target.com",
"subject": "Confirm your registration",
"excerpt": "Click the link below to activate your account...",
"ts": 1717200100,
"read": false,
"body": "<html>...</html>"
}
]
}
- If the file exists and the session is still valid (< 18 min inactivity, email not expired), reuse it.
- Append new emails as they arrive. Do not overwrite existing messages.
- Mark
read: trueafter the agent fetches and processes the email body. - Save after every meaningful state change (new email fetched, session created).
Session Management
Use this template to initialize or resume a session:
import requests
import json
import time
import os
import re
API_BASE = "http://api.guerrillamail.com/ajax.php"
def get_or_create_session(state_file: str) -> dict:
"""Load existing state or create a fresh session."""
if os.path.exists(state_file):
with open(state_file) as f:
state = json.load(f)
# Session may have expired — attempt email check to validate
return state
params = {"f": "get_email_address", "ip": "0.0.0.0", "agent": "Mozilla_pentest_agent"}
resp = requests.get(API_BASE, params=params)
sid = extract_phpsessid(resp)
data = resp.json()
state = {
"session": {
"email": data["email_addr"],
"sid": sid,
"created_at": data["email_timestamp"]
},
"inbox": []
}
save_state(state_file, state)
print(f"[+] Session created: {data['email_addr']} (SID: {sid[:8]}...)")
return state
def extract_phpsessid(resp: requests.Response) -> str:
"""Extract PHPSESSID from Set-Cookie header."""
if "Set-Cookie" not in resp.headers:
return ""
match = re.search(r"PHPSESSID=([a-zA-Z0-9]+);", resp.headers["Set-Cookie"])
return match.group(1) if match else ""
def save_state(path: str, state: dict):
with open(path, "w") as f:
json.dump(state, f, indent=2)
Instructions to the agent:
- Always capture
PHPSESSIDfrom theSet-Cookieheader on every response — it can change at any time - The
Set-Cookieheader is in the HTTP response headers, not the JSON body - Pass the SID via the
Cookieheader on every request:cookies = {"PHPSESSID": state["session"]["sid"]} requests.get(API_BASE, params=params, cookies=cookies) - If the SID changes, update
state["session"]["sid"]immediately and save - If a request returns a 200 but the response is empty or unexpected, the session likely expired — create a new one and inform the user
Polling Loop
Use this template to wait for a specific email. Set timeout based on the use case (default: 60s).
def poll_for_email(
state: dict,
state_file: str,
timeout: int = 60,
match_from: str = "",
match_subject: str = "",
match_body: str = "",
interval: int = 5
) -> dict | None:
"""Poll check_email until a matching email arrives or timeout."""
import urllib.parse
params = {
"f": "check_email",
"ip": "0.0.0.0",
"agent": "Mozilla_pentest_agent",
"seq": "0"
}
cookies = {"PHPSESSID": state["session"]["sid"]}
deadline = time.time() + timeout
while time.time() < deadline:
resp = requests.get(API_BASE, params=params, cookies=cookies)
# Update SID if server changed it
new_sid = extract_phpsessid(resp)
if new_sid and new_sid != state["session"]["sid"]:
state["session"]["sid"] = new_sid
cookies["PHPSESSID"] = new_sid
data = resp.json()
remaining = int(deadline - time.time())
print(f" [~] {len(data.get('list', []))} emails, {remaining}s remaining")
for mail in data.get("list", []):
if mail["mail_id"] in [m["id"] for m in state["inbox"]]:
continue # already processed
if match_from and match_from.lower() not in mail.get("mail_from", "").lower():
continue
if match_subject and match_subject.lower() not in mail.get("mail_subject", "").lower():
continue
# Matched — fetch full body
mail["read"] = False
state["inbox"].append({
"id": mail["mail_id"],
"from": mail["mail_from"],
"subject": mail["mail_subject"],
"excerpt": mail["mail_excerpt"],
"ts": mail["mail_timestamp"],
"read": False,
"body": None # fetched on demand
})
save_state(state_file, state)
return mail
time.sleep(interval)
print(" [!] Timeout reached — email did not arrive")
return None
Instructions to the agent:
- Start polling after the action that should trigger the email (e.g., after submitting a registration form)
- Log each poll attempt so the user sees progress
- Use
match_from(sender domain),match_subject(keyword), ormatch_body(keyword in excerpt) — at least one filter - If the target sends multiple emails (e.g., welcome + confirmation), poll in a loop calling
fetch_emailfor each - The
mail_excerptfield is HTML-entity-escaped — usehtml.unescape()if needed
Fetching Email Body
Once a matching email is found in the inbox list, fetch its full body:
def fetch_email_body(state: dict, mail_id: int) -> str | None:
"""Fetch the full HTML body of an email by ID."""
params = {
"f": "fetch_email",
"ip": "0.0.0.0",
"agent": "Mozilla_pentest_agent",
"email_id": mail_id
}
cookies = {"PHPSESSID": state["session"]["sid"]}
resp = requests.get(API_BASE, params=params, cookies=cookies)
data = resp.json()
# The body is in data["mail_body"] or similar — inspect the response
if isinstance(data, list):
data = data[0]
body = data.get("mail_body", data.get("body", ""))
return body
Instructions to the agent:
- Always fetch the body after matching an email in the poll loop
- Save the body to the inbox entry and mark
read: true - The GM API returns the email body in
mail_bodyfield of the fetch response - The body is already HTML-filtered by Guerrilla Mail (scripts, iframes, applets removed)
Displaying Images (res.php replacement)
Guerrilla Mail replaces image sources with links to their proxy:
/res.php?r=1&n=img&q=<original_url_encoded>
To view real images, replace these references:
import urllib.parse
import html
def resolve_gm_images(html_body: str) -> str:
"""Replace Guerrilla Mail image proxies with original URLs."""
# Match within double quotes
html_body = re.sub(
r'"/res\.php\?r=1&n=[a-z]+&q=([^"^&]+)"',
lambda m: '"' + urllib.parse.unquote(m.group(1)) + '"',
html_body
)
# Match within HTML-encoded quotes
html_body = re.sub(
r'"/res\.php\?r=1&n=[a-z]+&q=([^"]+)"',
lambda m: '"' + urllib.parse.unquote(m.group(1)) + '"',
html_body
)
return html_body
Only apply this replacement when the user asks to see images. By default, save the body with proxy URLs intact.
Use Cases
Registration Confirmation
Scenario: User wants to register on a target site and confirm the account via email.
Workflow:
def workflow_registration(target_url: str, state_file: str):
# 1. Create session
state = get_or_create_session(state_file)
print(f"[+] Using email: {state['session']['email']}")
print(f"[+] Register at: {target_url}")
print("[!] PAUSE — complete the registration form manually, then press Enter")
input()
# 2. Poll for confirmation email
mail = poll_for_email(
state, state_file,
match_subject="confirm",
match_from=extract_domain(target_url),
timeout=60
)
if not mail:
print("[-] Confirmation email not received")
return
# 3. Fetch body and extract link
body = fetch_email_body(state, mail["mail_id"])
confirm_link = extract_link_from_body(body)
if confirm_link:
print(f"[+] Confirmation link: {confirm_link}")
else:
print("[!] No link found in email body — check manually")
def extract_domain(url: str) -> str:
from urllib.parse import urlparse
return urlparse(url).netloc
def extract_link_from_body(html_body: str) -> str | None:
"""Find first https?:// link in HTML body."""
match = re.search(r'https?://[^\s"'<>]+', html_body)
return match.group(0) if match else None
Password Reset
Scenario: User wants to trigger and capture a password reset link.
Workflow:
Same base as registration, but:
- Match subject on
"reset"or"password" - Extract the reset token/link from the body
- Optionally replace the reset link domain to test SSRF or host-header injection if applicable
def workflow_password_reset(target_url: str, state_file: str):
state = get_or_create_session(state_file)
print(f"[+] Using email: {state['session']['email']}")
print(f"[+] Trigger password reset at: {target_url}")
print("[!] PAUSE — submit the password reset form, then press Enter")
input()
mail = poll_for_email(
state, state_file,
match_subject="reset",
timeout=60
)
if not mail:
print("[-] Reset email not received")
return
body = fetch_email_body(state, mail["mail_id"])
reset_link = extract_link_from_body(body)
if reset_link:
print(f"[+] Reset link: {reset_link}")
return body # return for further analysis
Data Leakage Detection
Scenario: User wants to test what sensitive data the target leaks in transactional emails.
Workflow:
- Create session and register for an action that triggers an email
- Fetch the full email body
- Scan for sensitive patterns:
LEAK_PATTERNS = {
"email": r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
"phone": r"\+?[\d\s\-\(\)]{7,20}",
"cpf": r"\d{3}\.?\d{3}\.?\d{3}-?\d{2}",
"credit_card": r"\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}",
"ip_internal": r"(10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})",
"api_key": r"(?i)(api[_-]?key|apikey|secret|token)[:=]\s*['\"]?[a-zA-Z0-9_\-]{16,}"
}
def scan_for_leaks(body: str) -> dict:
findings = {}
for name, pattern in LEAK_PATTERNS.items():
matches = re.findall(pattern, body)
if matches:
findings[name] = list(set(matches))
return findings
Document any findings in the inbox state file notes and report to the user.
Endpoint Validation
Scenario: User wants to confirm that an email-sending endpoint actually fires (e.g., contact form, invite system, notification mechanism).
Workflow:
def workflow_validate_endpoint(target_url: str, state_file: str):
state = get_or_create_session(state_file)
print(f"[+] Target email: {state['session']['email']}")
print(f"[+] Submit at: {target_url}")
print("[!] PAUSE — trigger the endpoint (submit the form), then press Enter")
input()
# Check for any incoming email (no specific match filter)
mail = poll_for_email(state, state_file, timeout=90)
if mail:
print(f"[+] Email received from: {mail['mail_from']}")
print(f"[+] Subject: {mail['mail_subject']}")
return True
else:
print("[-] No email received — endpoint may not be firing")
return False
Rate Limit / Spam Testing
Scenario: User wants to test how many emails the target sends before rate-limiting or to collect a sample.
Workflow:
def workflow_rate_limit_test(target_url: str, state_file: str, max_emails: int = 20):
state = get_or_create_session(state_file)
print(f"[+] Using email: {state['session']['email']}")
print(f"[+] Target: {target_url}")
print(f"[+] Collecting up to {max_emails} emails")
print("[!] PAUSE — trigger the email-generating actions, then press Enter")
input()
collected = 0
deadline = time.time() + 180 # 3 min max for batch
while collected < max_emails and time.time() < deadline:
mail = poll_for_email(state, state_file, timeout=30, interval=5)
if not mail:
break
body = fetch_email_body(state, mail["mail_id"])
collected += 1
print(f" [{collected}/{max_emails}] {mail['mail_from']} - {mail['mail_subject']}")
print(f"\n[+] Collected {collected} emails total")
print(f"[+] State saved in: {state_file}")
API Reference Summary
| Function | Params | Returns | Notes |
|---|---|---|---|
get_email_address |
lang (optional) |
email_addr, email_timestamp |
Initializes session. Returns existing session if SID is valid. |
set_email_user |
email_user, lang |
Same as get_email_address | Sets a custom email username. Requires valid SID. |
check_email |
seq |
list, count, email, ts |
Returns up to 20 newest messages. |
get_email_list |
offset, seq |
Same as check_email | Paginated email list. offset=0 → first 20. |
fetch_email |
email_id |
Full email with body | Only emails owned by current session. |
forget_me |
email_addr |
true |
Forgets current address, keeps session alive. |
del_email |
email_ids[] |
Array of deleted IDs | Delete one or more emails. |
extend |
(none) | expired, email_timestamp, affected |
Extends address lifetime by 1h (max 2h). |
Troubleshooting
Problem: requests not installed
Solution: uv pip install requests or uv add requests
Problem: API returns empty or unexpected response Causes:
- Session expired (SID invalid after ~18 min inactivity) → create new session
- IP blocked (rate limiting) → wait 30s and retry
- HTTP (not HTTPS) blocked by environment → inform user, suggest alternative
Problem: Set-Cookie header not found
Cause: Python's requests by default does not expose Set-Cookie. Use resp.headers.get("Set-Cookie", "") or access resp.cookies directly:
sid = resp.cookies.get("PHPSESSID", "")
Problem: PHPSESSID changed mid-session Fix: Always extract SID from the last response before each request. Update state immediately.
Problem: Email never arrives (poll timeout) Check:
- Is the user's trigger action correct? (form submitted, email sent?)
- Is the match filter too narrow? Try matching on fewer fields
- Is the target actually sending emails? Sometimes they queue or delay