- feat: new /api/parsed_mails and /api/parsed_mail/:id endpoints returning server-parsed subject/text/html/attachments metadata (reuses commonParseMail) - feat: add .claude/skills/cf-temp-mail-usage read-only skill so AI agents (OpenClaw / Codex / Cursor) can consume a mailbox with a user-supplied JWT, bypassing the Turnstile challenge required for mailbox creation - refactor: split mails_api/index.ts and admin_api/index.ts into thin route shells; move business logic into dedicated *_api.ts files - docs: update README / README_EN / CHANGELOG with agent-email feature and npx degit install instructions for the skill Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
6.3 KiB
name, description
| name | description |
|---|---|
| cf-temp-mail-usage | Read mails from a cloudflare_temp_email mailbox using a user-supplied Address JWT and API base URL. Use when the user (or an agent such as OpenClaw / Codex / Cursor) needs to list the inbox, fetch a specific message, or extract a verification code / magic link. Prefers the server-parsed endpoints so the agent gets subject/text/html/attachments directly. Does NOT handle mailbox creation — the user provides the JWT themselves. |
Temp-Mail Read-Only Usage
Consume an existing mailbox. The user hands over the JWT (obtained in a browser after creating an address); the agent only reads mail.
Inputs the user must provide
BASE— API base URL, e.g.https://mail.example.comor the Worker's*.workers.devhost.JWT— Address JWT. In the frontend it is stored inlocalStorageunder the keyjwt(raw string, no JSON wrap).- (optional)
SITE_PASSWORD— only if the deployment enabledx-custom-auth.
If anything is missing, ask the user before making requests.
Required headers
Authorization: Bearer <JWT>— on every/api/*request.x-custom-auth: <SITE_PASSWORD>— only when the site requires it.x-lang: enorzh— optional, error-message language.
Do not send the Address JWT as x-user-token — that is a different JWT type and will yield 401 InvalidAddressCredentialMsg.
Endpoints (read-only)
| Task | Method | Path | Returns |
|---|---|---|---|
| Address info | GET | /api/settings |
{ address, send_balance } |
| List parsed mails | GET | /api/parsed_mails?limit=&offset= |
{ results: [parsedMail], count } |
| Get one parsed mail | GET | /api/parsed_mail/:id |
parsedMail |
| List raw mails | GET | /api/mails?limit=&offset= |
{ results: [{...,raw}], count } |
| Get one raw mail | GET | /api/mail/:id |
{ ..., raw } |
limit 1–100, offset 0-based. On 429, back off.
Prefer the parsed_* endpoints. They run the same commonParseMail (postal-mime) the frontend uses and return structured fields directly, so the agent does not need to ship a MIME parser.
parsedMail shape:
{
"id": 42,
"message_id": "<...>",
"source": "noreply@foo.com",
"to": "abc@yourdomain.com",
"created_at": "2026-04-21 10:00:00",
"sender": "Foo <noreply@foo.com>",
"subject": "Your code is 123456",
"text": "Your code is 123456\n",
"html": "<p>Your code is <b>123456</b></p>",
"attachments": [
{ "filename": "a.pdf", "mimeType": "application/pdf", "disposition": "attachment", "size": 12345 }
]
}
Attachment binary content is not included in parsed_* responses — only metadata. If you need the bytes, fetch the raw mail via /api/mail/:id and parse it client-side (see below).
Recipes
1. Smoke-test the JWT
curl -s "$BASE/api/settings" -H "Authorization: Bearer $JWT"
# → { "address": "abc123@example.com", "send_balance": 0 }
If this returns 401, JWT is wrong / expired / mismatched with BASE — ask the user for a fresh one.
2. List the inbox (parsed)
curl -s "$BASE/api/parsed_mails?limit=20&offset=0" \
-H "Authorization: Bearer $JWT"
3. Get one mail (parsed)
curl -s "$BASE/api/parsed_mail/<id>" -H "Authorization: Bearer $JWT"
4. Extract a verification code (end-to-end, parsed API)
import re, time, requests
BASE, JWT = "<BASE>", "<JWT>"
H = {"Authorization": f"Bearer {JWT}"}
def wait_for_code(pattern=r"\b\d{4,8}\b", timeout=120, poll=3):
deadline = time.time() + timeout
seen = set()
while time.time() < deadline:
lst = requests.get(f"{BASE}/api/parsed_mails?limit=5&offset=0", headers=H).json()
for m in lst.get("results", []):
if m["id"] in seen: continue
seen.add(m["id"])
body = (m.get("subject") or "") + "\n" + (m.get("text") or "") + "\n" + (m.get("html") or "")
hit = re.search(pattern, body)
if hit:
return hit.group(0)
time.sleep(poll)
raise TimeoutError("no matching mail within window")
print(wait_for_code())
Raw endpoints (fallback — only if you need attachment bytes or the original MIME)
/api/mails and /api/mail/:id return the gzip-resolved RFC822 source in raw. Parse it client-side.
Node.js (postal-mime, pure JS)
npm i postal-mime
import PostalMime from 'postal-mime';
const mail = await (await fetch(`${BASE}/api/mail/${id}`, {
headers: { Authorization: `Bearer ${JWT}` },
})).json();
const parsed = await PostalMime.parse(mail.raw);
// parsed.subject / parsed.from / parsed.text / parsed.html
// parsed.attachments[i].content is a Uint8Array
Python (stdlib, no deps)
import email, requests
from email import policy
r = requests.get(f"{BASE}/api/mail/{mid}", headers={"Authorization": f"Bearer {JWT}"}).json()
msg = email.message_from_string(r["raw"], policy=policy.default)
subject = msg["subject"]
text = (msg.get_body(preferencelist=("plain",)) or None) and msg.get_body(preferencelist=("plain",)).get_content()
html = (msg.get_body(preferencelist=("html",)) or None) and msg.get_body(preferencelist=("html",)).get_content()
for part in msg.iter_attachments():
name, mime, data = part.get_filename(), part.get_content_type(), part.get_content()
The frontend's reference implementation is frontend/src/utils/email-parser.js — tries mail-parser-wasm first, falls back to postal-mime. The server uses postal-mime only.
Polling discipline
- Start at
poll=3s, exponential backoff capped at 10s. - Dedupe by mail
id. - Never poll faster than once per second.
- Respect
429— sleep and retry.
Common errors
401 InvalidAddressCredentialMsg— JWT wrong/expired/sent via wrong header. Ask the user for a fresh JWT.401 CustomAuthPasswordMsg— site requiresx-custom-auth; attachSITE_PASSWORD.400 InvalidLimitMsg/InvalidOffsetMsg—limitmust be 1..100,offset ≥ 0.429— rate limited; back off.