tossinbox 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -9
- package/dist/cli.js +116 -1
- package/dist/core/index.js +3 -2
- package/dist/core/net.js +46 -0
- package/dist/core/otp.js +28 -2
- package/dist/core/providers/guerrillamail.js +5 -1
- package/dist/core/providers/mailtm.js +138 -114
- package/dist/core/state.js +23 -4
- package/dist/version.js +1 -1
- package/llms.txt +17 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ $ tossinbox toss
|
|
|
55
55
|
| Documented exit codes | 0–4 | none | no |
|
|
56
56
|
| MCP server for agents | yes, built in | no | no |
|
|
57
57
|
| Runs headless / in CI | yes | no | partial |
|
|
58
|
-
| Upstream alive | mail.tm + GuerrillaMail | varies | many wrap the dead 1secmail |
|
|
58
|
+
| Upstream alive | mail.tm + mail.gw + GuerrillaMail | varies | many wrap the dead 1secmail |
|
|
59
59
|
| Ads, trackers, popups | none | the business model | none |
|
|
60
60
|
|
|
61
61
|
Checked September 2026. If a cell is wrong, open an issue and win the argument.
|
|
@@ -68,11 +68,11 @@ Requires Node.js 18+.
|
|
|
68
68
|
# Homebrew (macOS, Linux)
|
|
69
69
|
brew install mohamed-khairy-5i/tap/tossinbox
|
|
70
70
|
|
|
71
|
-
# npm
|
|
72
|
-
npm install -g
|
|
71
|
+
# npm (npmjs.com)
|
|
72
|
+
npm install -g tossinbox
|
|
73
73
|
|
|
74
74
|
# Or run without installing
|
|
75
|
-
npx
|
|
75
|
+
npx tossinbox@latest spawn
|
|
76
76
|
```
|
|
77
77
|
|
|
78
78
|
From source:
|
|
@@ -111,6 +111,7 @@ tossinbox wait --code --json
|
|
|
111
111
|
| `list` | List messages (`-a address`) |
|
|
112
112
|
| `read <id>` | Read a full message, including any detected code |
|
|
113
113
|
| `wait` | Poll until a message arrives (`-f sender`, `-s subject`, `-c` extract code, `-t timeout` max 600s) |
|
|
114
|
+
| `watch` | Stream new messages until Ctrl-C — only new arrivals; `--json` = one JSON object per line (NDJSON) |
|
|
114
115
|
| `inboxes` | List locally saved inboxes |
|
|
115
116
|
| `toss` | Delete an inbox server-side and remove it from local state (`--all` for every inbox) |
|
|
116
117
|
| `clear` | Remove all inboxes from local state only |
|
|
@@ -164,13 +165,14 @@ TossInbox ships with an MCP server exposing four tools:
|
|
|
164
165
|
"mcpServers": {
|
|
165
166
|
"tossinbox": {
|
|
166
167
|
"command": "npx",
|
|
167
|
-
"args": ["-y", "
|
|
168
|
+
"args": ["-y", "tossinbox", "mcp"]
|
|
168
169
|
}
|
|
169
170
|
}
|
|
170
171
|
}
|
|
171
172
|
```
|
|
172
173
|
|
|
173
|
-
Or after a global install, simply use `tossinbox-mcp` as the command
|
|
174
|
+
Or after a global install, simply use `tossinbox-mcp` as the command —
|
|
175
|
+
equivalent to `npx -y tossinbox mcp`.
|
|
174
176
|
|
|
175
177
|
### Works with any AI agent
|
|
176
178
|
|
|
@@ -214,6 +216,7 @@ machine-readable and kept up to date.
|
|
|
214
216
|
| Provider | API key | Notes |
|
|
215
217
|
|---|---|---|
|
|
216
218
|
| `mailtm` (default) | not required | mail.tm — reliable, fast |
|
|
219
|
+
| `mailgw` | not required | mail.gw — mail.tm-compatible API on independent infrastructure |
|
|
217
220
|
| `guerrillamail` | not required | GuerrillaMail — classic fallback |
|
|
218
221
|
|
|
219
222
|
Adding a provider means implementing a small interface (`createInbox`,
|
|
@@ -222,14 +225,14 @@ Adding a provider means implementing a small interface (`createInbox`,
|
|
|
222
225
|
## FAQ
|
|
223
226
|
|
|
224
227
|
**Is it really free?**
|
|
225
|
-
Yes. MIT-licensed, and
|
|
228
|
+
Yes. MIT-licensed, and all three upstream providers are free with no API keys.
|
|
226
229
|
|
|
227
230
|
**Can it send email?**
|
|
228
231
|
No — receive-only by design. TossInbox exists for privacy and testing and
|
|
229
232
|
ships no bulk-send or bulk-signup mode.
|
|
230
233
|
|
|
231
234
|
**Does it work on Windows?**
|
|
232
|
-
Yes, anywhere Node.js 18+ runs. `npx
|
|
235
|
+
Yes, anywhere Node.js 18+ runs. `npx tossinbox@latest spawn`
|
|
233
236
|
works in PowerShell exactly the same.
|
|
234
237
|
|
|
235
238
|
**A site blocked my disposable address. What now?**
|
|
@@ -252,7 +255,7 @@ round.
|
|
|
252
255
|
- [x] GitHub Action: `mohamed-khairy-5i/tossinbox@v1`
|
|
253
256
|
- [x] Homebrew tap: `brew install mohamed-khairy-5i/tap/tossinbox`
|
|
254
257
|
- [x] Project website at [tossinbox.pages.dev](https://tossinbox.pages.dev/)
|
|
255
|
-
- [
|
|
258
|
+
- [x] Publish `tossinbox` + `tossinbox-mcp` to the [npm registry](https://www.npmjs.com/package/tossinbox)
|
|
256
259
|
- [ ] `mail.gw` provider (mail.tm-compatible API — small lift)
|
|
257
260
|
- [ ] `tempmail.lol` provider (free API)
|
|
258
261
|
- [ ] Provider failover: auto-switch when a provider is down
|
|
@@ -264,6 +267,9 @@ round.
|
|
|
264
267
|
- [CLI reference](https://tossinbox.pages.dev/cli) — every command, flag, and exit code
|
|
265
268
|
- [Agents & MCP](https://tossinbox.pages.dev/agents) — setup for every MCP client
|
|
266
269
|
- [FAQ](https://tossinbox.pages.dev/faq) — privacy, providers, troubleshooting
|
|
270
|
+
- [Examples](https://tossinbox.pages.dev/examples) — copy-paste recipes: shell, CI, Node.js, MCP
|
|
271
|
+
- [Guide](https://tossinbox.pages.dev/guide) — providers, state, flags, exit codes, troubleshooting
|
|
272
|
+
- [Roadmap](https://tossinbox.pages.dev/roadmap) — what shipped and what is next
|
|
267
273
|
- [Changelog](./CHANGELOG.md) · [Contributing](./CONTRIBUTING.md) · [Security](./SECURITY.md)
|
|
268
274
|
|
|
269
275
|
## License
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command, CommanderError } from "commander";
|
|
3
|
-
import { DEFAULT_PROVIDER, getProvider, listProviders, listSavedInboxes, removeInbox, clearInboxes, resolveInbox, saveInbox, statePath, waitForMessage, htmlToText, ProviderError, } from "./core/index.js";
|
|
3
|
+
import { DEFAULT_PROVIDER, getProvider, listProviders, listSavedInboxes, removeInbox, clearInboxes, resolveInbox, saveInbox, statePath, waitForMessage, sleep, htmlToText, ProviderError, } from "./core/index.js";
|
|
4
4
|
import { VERSION } from "./version.js";
|
|
5
5
|
/* Documented exit codes:
|
|
6
6
|
* 0 success
|
|
@@ -212,6 +212,121 @@ program
|
|
|
212
212
|
fail(err);
|
|
213
213
|
}
|
|
214
214
|
});
|
|
215
|
+
program
|
|
216
|
+
.command("watch")
|
|
217
|
+
.description("Stream new messages as they arrive — prints each message (and its code when found) and keeps polling until Ctrl-C")
|
|
218
|
+
.option("-a, --address <address>", "inbox address (defaults to the most recent inbox)")
|
|
219
|
+
.option("-f, --from <sender>", "only report messages from this sender (substring)")
|
|
220
|
+
.option("-s, --subject <text>", "only report messages whose subject contains this text")
|
|
221
|
+
.option("-i, --interval <seconds>", "poll interval in seconds", "5")
|
|
222
|
+
.action(async (opts) => {
|
|
223
|
+
try {
|
|
224
|
+
const intervalSeconds = Number(opts.interval);
|
|
225
|
+
if (!Number.isFinite(intervalSeconds) || intervalSeconds < 1 || intervalSeconds > 60) {
|
|
226
|
+
fail(new Error(`Invalid --interval "${opts.interval}" (expected seconds between 1 and 60)`), EXIT_USAGE);
|
|
227
|
+
}
|
|
228
|
+
const inbox = await resolveInbox(opts.address);
|
|
229
|
+
if (!inbox)
|
|
230
|
+
fail(new Error("No saved inbox found. Run: tossinbox spawn"), EXIT_NOT_FOUND);
|
|
231
|
+
const provider = providerOrExit(inbox.provider);
|
|
232
|
+
const matches = (m) => {
|
|
233
|
+
if (opts.from) {
|
|
234
|
+
const hay = `${m.from} ${m.fromName ?? ""}`.toLowerCase();
|
|
235
|
+
if (!hay.includes(opts.from.toLowerCase()))
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
if (opts.subject && !m.subject.toLowerCase().includes(opts.subject.toLowerCase()))
|
|
239
|
+
return false;
|
|
240
|
+
return true;
|
|
241
|
+
};
|
|
242
|
+
// Snapshot what is already in the inbox so only NEW arrivals are
|
|
243
|
+
// reported — re-watching an inbox after a wait must not replay history.
|
|
244
|
+
const seen = new Set();
|
|
245
|
+
try {
|
|
246
|
+
for (const m of await provider.listMessages(inbox))
|
|
247
|
+
seen.add(m.id);
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// A failed first poll should not blind the watcher — start empty.
|
|
251
|
+
}
|
|
252
|
+
let stopped = false;
|
|
253
|
+
const stop = () => {
|
|
254
|
+
stopped = true;
|
|
255
|
+
};
|
|
256
|
+
process.on("SIGINT", stop);
|
|
257
|
+
process.on("SIGTERM", stop);
|
|
258
|
+
if (jsonMode()) {
|
|
259
|
+
// Streaming mode: one compact JSON object per line (NDJSON).
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
console.error(`👀 watching ${inbox.address} — Ctrl-C to stop`);
|
|
263
|
+
}
|
|
264
|
+
while (!stopped) {
|
|
265
|
+
let summaries;
|
|
266
|
+
try {
|
|
267
|
+
summaries = await provider.listMessages(inbox);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// Transient provider/network errors: keep polling until stopped.
|
|
271
|
+
}
|
|
272
|
+
if (summaries) {
|
|
273
|
+
for (const summary of summaries) {
|
|
274
|
+
if (seen.has(summary.id) || !matches(summary))
|
|
275
|
+
continue;
|
|
276
|
+
seen.add(summary.id);
|
|
277
|
+
// One retry — a transient read error must not swallow a message
|
|
278
|
+
// that may have taken minutes to arrive.
|
|
279
|
+
let message;
|
|
280
|
+
try {
|
|
281
|
+
message = await provider.readMessage(inbox, summary.id);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
await sleep(1500);
|
|
285
|
+
try {
|
|
286
|
+
message = await provider.readMessage(inbox, summary.id);
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
// Report from the summary rather than dropping the event.
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (jsonMode()) {
|
|
293
|
+
const event = {
|
|
294
|
+
ok: true,
|
|
295
|
+
event: "message",
|
|
296
|
+
inbox: inbox.address,
|
|
297
|
+
provider: inbox.provider,
|
|
298
|
+
id: summary.id,
|
|
299
|
+
from: summary.from,
|
|
300
|
+
fromName: summary.fromName,
|
|
301
|
+
subject: summary.subject,
|
|
302
|
+
createdAt: summary.createdAt,
|
|
303
|
+
code: message?.code,
|
|
304
|
+
text: message?.text ?? (message?.html ? htmlToText(message.html) : undefined),
|
|
305
|
+
};
|
|
306
|
+
console.log(JSON.stringify(event));
|
|
307
|
+
}
|
|
308
|
+
else if (message) {
|
|
309
|
+
printMessageHuman(message, false);
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
console.log(`✔ new message: ${summary.subject}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// Sleep in small slices so Ctrl-C feels instant.
|
|
317
|
+
const deadline = Date.now() + intervalSeconds * 1000;
|
|
318
|
+
while (!stopped && Date.now() < deadline) {
|
|
319
|
+
await sleep(Math.min(200, deadline - Date.now()));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (!jsonMode())
|
|
323
|
+
console.error("✔ watch stopped");
|
|
324
|
+
exitWith(EXIT_OK);
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
fail(err);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
215
330
|
program
|
|
216
331
|
.command("inboxes")
|
|
217
332
|
.description("List locally saved inboxes")
|
package/dist/core/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { mailTm } from "./providers/mailtm.js";
|
|
1
|
+
import { mailTm, mailGw } from "./providers/mailtm.js";
|
|
2
2
|
import { guerrillaMail } from "./providers/guerrillamail.js";
|
|
3
3
|
export * from "./types.js";
|
|
4
4
|
export { extractCode, htmlToText } from "./otp.js";
|
|
5
5
|
export * from "./state.js";
|
|
6
|
-
export { waitForMessage } from "./wait.js";
|
|
6
|
+
export { waitForMessage, sleep } from "./wait.js";
|
|
7
7
|
export const providers = {
|
|
8
8
|
[mailTm.name]: mailTm,
|
|
9
|
+
[mailGw.name]: mailGw,
|
|
9
10
|
[guerrillaMail.name]: guerrillaMail,
|
|
10
11
|
};
|
|
11
12
|
export const DEFAULT_PROVIDER = mailTm.name;
|
package/dist/core/net.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ProviderError } from "./types.js";
|
|
2
|
+
/** Translate Node's raw fetch failures into a ProviderError that says what
|
|
3
|
+
* actually happened and what to do next. Without this, a DNS blip or a dead
|
|
4
|
+
* upstream surfaces as Node's bare "fetch failed" — useless to humans and
|
|
5
|
+
* unparsable for agents. Messages stay short, actionable, and stable. */
|
|
6
|
+
export function networkError(err, provider, host, timeoutMs) {
|
|
7
|
+
const failure = (err ?? {});
|
|
8
|
+
const cause = failure.cause ?? {};
|
|
9
|
+
const code = cause.code ?? "";
|
|
10
|
+
// AbortSignal.timeout rejects with a DOMException named "TimeoutError"
|
|
11
|
+
// (sometimes wrapped as the fetch cause).
|
|
12
|
+
if (failure.name === "TimeoutError" ||
|
|
13
|
+
cause.name === "TimeoutError" ||
|
|
14
|
+
code === "UND_ERR_HEADERS_TIMEOUT") {
|
|
15
|
+
return new ProviderError(provider, `no response from ${host} within ${Math.round(timeoutMs / 1000)}s — ` +
|
|
16
|
+
"provider is down or unreachable; retry, or switch with --provider");
|
|
17
|
+
}
|
|
18
|
+
switch (code) {
|
|
19
|
+
case "ENOTFOUND":
|
|
20
|
+
case "EAI_AGAIN":
|
|
21
|
+
return new ProviderError(provider, `DNS lookup failed for ${host} — no internet, bad DNS, or a blocked ` +
|
|
22
|
+
"network; check connectivity and retry");
|
|
23
|
+
case "ECONNREFUSED":
|
|
24
|
+
return new ProviderError(provider, `connection refused by ${host} — provider may be down, or a ` +
|
|
25
|
+
"firewall/proxy is blocking it; retry later");
|
|
26
|
+
case "ECONNRESET":
|
|
27
|
+
case "EPIPE":
|
|
28
|
+
return new ProviderError(provider, `connection to ${host} dropped mid-request — usually transient; retry`);
|
|
29
|
+
case "ENETUNREACH":
|
|
30
|
+
case "ENETDOWN":
|
|
31
|
+
case "EHOSTUNREACH":
|
|
32
|
+
return new ProviderError(provider, "network unreachable — you appear to be offline; check your connection and retry");
|
|
33
|
+
case "ECONNABORTED":
|
|
34
|
+
return new ProviderError(provider, `connection to ${host} aborted — usually transient; retry`);
|
|
35
|
+
case "CERT_HAS_EXPIRED":
|
|
36
|
+
case "DEPTH_ZERO_SELF_SIGNED_CERT":
|
|
37
|
+
case "SELF_SIGNED_CERT_IN_CHAIN":
|
|
38
|
+
case "UNABLE_TO_VERIFY_LEAF_SIGNATURE":
|
|
39
|
+
return new ProviderError(provider, `TLS certificate problem with ${host} — if a corporate proxy ` +
|
|
40
|
+
"intercepts TLS, point NODE_EXTRA_CA_CERTS at its CA bundle");
|
|
41
|
+
default: {
|
|
42
|
+
const detail = cause.message || failure.message || String(err);
|
|
43
|
+
return new ProviderError(provider, `cannot reach ${host} (${detail}) — check connectivity and retry`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
package/dist/core/otp.js
CHANGED
|
@@ -6,8 +6,34 @@
|
|
|
6
6
|
* looks like a code (4-8 digits, or 5-8 uppercase alphanumeric chars
|
|
7
7
|
* containing both letters and digits).
|
|
8
8
|
* 2. Fall back to the first standalone 4-8 digit number in the text.
|
|
9
|
+
*
|
|
10
|
+
* Keywords cover English plus the languages verification mail actually
|
|
11
|
+
* arrives in: Arabic, French, Spanish, German, Portuguese, Italian, Russian,
|
|
12
|
+
* Turkish, Chinese, Japanese, and Korean. Latin-script words use \b
|
|
13
|
+
* boundaries; scripts where \b is meaningless (Arabic, Cyrillic, CJK) match
|
|
14
|
+
* bare, same as the existing Arabic handling.
|
|
9
15
|
*/
|
|
10
|
-
const CODE_KEYWORDS = /\b(?:code|otp|passcode|pin|password.?code|verification|verify|confirm|activation|active.?code|one.?time)\b
|
|
16
|
+
const CODE_KEYWORDS = /\b(?:code|otp|passcode|pin|password.?code|verification|verify|confirm|activation|active.?code|one.?time|code.?de.?confirmation)\b/ // en + fr "code de confirmation"
|
|
17
|
+
.source +
|
|
18
|
+
"|" +
|
|
19
|
+
[
|
|
20
|
+
// Arabic
|
|
21
|
+
"رمز", "كود", "تفعيل", "تحقق", "الرمز",
|
|
22
|
+
// French
|
|
23
|
+
"vérification", "vérifier", "confirmer", "confirmation",
|
|
24
|
+
// Spanish / Portuguese / Italian (shared words folded)
|
|
25
|
+
"código", "verificación", "verificar", "confirme", "confirmação", "verificação",
|
|
26
|
+
"codice", "verifica", "conferma",
|
|
27
|
+
// German
|
|
28
|
+
"bestätigung", "verifizierung", "bestätigungscode",
|
|
29
|
+
// Russian (Cyrillic — no \b)
|
|
30
|
+
"код", "подтверждение", "верификация",
|
|
31
|
+
// Turkish
|
|
32
|
+
"doğrulama", "onay.?kodu",
|
|
33
|
+
// Chinese / Japanese / Korean (CJK — no \b)
|
|
34
|
+
"验证码", "校验码", "确认码", "確認コード", "認証コード", "検証コード", "認証番号", "인증코드", "인증 번호",
|
|
35
|
+
].join("|");
|
|
36
|
+
const CODE_KEYWORDS_RE = new RegExp(CODE_KEYWORDS, "i");
|
|
11
37
|
export function htmlToText(html) {
|
|
12
38
|
return html
|
|
13
39
|
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
@@ -33,7 +59,7 @@ export function extractCode(input) {
|
|
|
33
59
|
const lines = text.split(/\r?\n/);
|
|
34
60
|
// 1) Keyword line -> prefer a code-shaped token on that line
|
|
35
61
|
for (const line of lines) {
|
|
36
|
-
if (!
|
|
62
|
+
if (!CODE_KEYWORDS_RE.test(line))
|
|
37
63
|
continue;
|
|
38
64
|
// Case-insensitive on purpose: many services send lowercase codes (f4x9k2).
|
|
39
65
|
const tokens = line.match(/(?<![\w-])[A-Za-z0-9]{4,10}(?![\w-])/g);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ProviderError } from "../types.js";
|
|
2
2
|
import { extractCode } from "../otp.js";
|
|
3
|
+
import { networkError } from "../net.js";
|
|
3
4
|
import { VERSION } from "../../version.js";
|
|
4
5
|
const BASE = "https://api.guerrillamail.com/ajax.php";
|
|
5
6
|
const REQUEST_TIMEOUT_MS = 20_000;
|
|
@@ -13,10 +14,13 @@ async function call(params) {
|
|
|
13
14
|
const url = new URL(BASE);
|
|
14
15
|
for (const [k, v] of Object.entries(params))
|
|
15
16
|
url.searchParams.set(k, v);
|
|
16
|
-
// Hard timeout on every request — an agent must never hang forever.
|
|
17
|
+
// Hard timeout on every request — an agent must never hang forever. Raw
|
|
18
|
+
// network failures are translated into a readable, actionable ProviderError.
|
|
17
19
|
const res = await fetch(url, {
|
|
18
20
|
headers: { Accept: "application/json", "User-Agent": `tossinbox/${VERSION}` },
|
|
19
21
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
22
|
+
}).catch((err) => {
|
|
23
|
+
throw networkError(err, "guerrillamail", "api.guerrillamail.com", REQUEST_TIMEOUT_MS);
|
|
20
24
|
});
|
|
21
25
|
if (!res.ok)
|
|
22
26
|
throw new ProviderError("guerrillamail", `HTTP ${res.status}`, res.status);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { ProviderError } from "../types.js";
|
|
3
3
|
import { extractCode } from "../otp.js";
|
|
4
|
+
import { networkError } from "../net.js";
|
|
4
5
|
import { VERSION } from "../../version.js";
|
|
5
|
-
const BASE = "https://api.mail.tm";
|
|
6
6
|
const REQUEST_TIMEOUT_MS = 20_000;
|
|
7
7
|
function randomString(length, alphabet) {
|
|
8
8
|
const bytes = randomBytes(length);
|
|
@@ -27,124 +27,148 @@ function headers(token) {
|
|
|
27
27
|
h.Authorization = `Bearer ${token}`;
|
|
28
28
|
return h;
|
|
29
29
|
}
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
};
|
|
41
|
-
let res = await fetchJson(url, init);
|
|
42
|
-
if (res.status === 429) {
|
|
43
|
-
await new Promise((r) => setTimeout(r, 1200));
|
|
44
|
-
res = await fetchJson(url, init);
|
|
30
|
+
/** mail.tm and mail.gw expose the identical API (mail.gw is an independent
|
|
31
|
+
* infrastructure running the same software), so one factory serves both.
|
|
32
|
+
* Every request gets a hard timeout and raw network failures are translated
|
|
33
|
+
* into readable, actionable ProviderErrors naming the right provider. */
|
|
34
|
+
function createMailTmLikeProvider(config) {
|
|
35
|
+
const { name: providerName, base, host } = config;
|
|
36
|
+
function fetchJson(url, init) {
|
|
37
|
+
return fetch(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }).catch((err) => {
|
|
38
|
+
throw networkError(err, providerName, host, REQUEST_TIMEOUT_MS);
|
|
39
|
+
});
|
|
45
40
|
}
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
41
|
+
/** These APIs allow ~8 requests per second; retry once on 429. */
|
|
42
|
+
async function request(method, url, options = {}) {
|
|
43
|
+
const init = {
|
|
44
|
+
method,
|
|
45
|
+
headers: { ...headers(options.token), ...(options.body ? { "Content-Type": "application/json" } : {}) },
|
|
46
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
47
|
+
};
|
|
48
|
+
let res = await fetchJson(url, init);
|
|
49
|
+
if (res.status === 429) {
|
|
50
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
51
|
+
res = await fetchJson(url, init);
|
|
56
52
|
}
|
|
57
|
-
|
|
58
|
-
|
|
53
|
+
return res;
|
|
54
|
+
}
|
|
55
|
+
async function parseJson(res) {
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
// 5xx = the upstream itself is failing — say so and point at the fix.
|
|
58
|
+
if (res.status >= 500) {
|
|
59
|
+
throw new ProviderError(providerName, `HTTP ${res.status} from ${host} — provider is down or having trouble; retry, or switch with --provider`, res.status);
|
|
60
|
+
}
|
|
61
|
+
let detail = "";
|
|
62
|
+
try {
|
|
63
|
+
const body = (await res.json());
|
|
64
|
+
const msg = body["hydra:description"] ?? body.message ?? body.detail;
|
|
65
|
+
if (typeof msg === "string")
|
|
66
|
+
detail = `: ${msg}`;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// ignore body parse errors
|
|
70
|
+
}
|
|
71
|
+
throw new ProviderError(providerName, `HTTP ${res.status}${detail}`, res.status);
|
|
59
72
|
}
|
|
60
|
-
|
|
73
|
+
return (await res.json());
|
|
61
74
|
}
|
|
62
|
-
return
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
/** These APIs return a plain array with Accept: application/json and a hydra
|
|
76
|
+
* collection with Accept: application/ld+json — normalize both. */
|
|
77
|
+
async function parseCollection(res) {
|
|
78
|
+
const data = await parseJson(res);
|
|
79
|
+
if (Array.isArray(data))
|
|
80
|
+
return data;
|
|
81
|
+
if (data && typeof data === "object") {
|
|
82
|
+
const obj = data;
|
|
83
|
+
const member = obj["hydra:member"] ?? obj.member;
|
|
84
|
+
if (Array.isArray(member))
|
|
85
|
+
return member;
|
|
86
|
+
}
|
|
87
|
+
throw new ProviderError(providerName, "Unexpected collection response shape");
|
|
75
88
|
}
|
|
76
|
-
|
|
89
|
+
return {
|
|
90
|
+
name: providerName,
|
|
91
|
+
description: config.description,
|
|
92
|
+
async createInbox(options) {
|
|
93
|
+
const domainsRes = await request("GET", `${base}/domains?page=1`);
|
|
94
|
+
const domains = await parseCollection(domainsRes);
|
|
95
|
+
const domain = domains.find((d) => d.isActive && !d.isPrivate)?.domain;
|
|
96
|
+
if (!domain) {
|
|
97
|
+
throw new ProviderError(providerName, `No active public domain available on ${host}`);
|
|
98
|
+
}
|
|
99
|
+
const address = `${randomUser()}@${domain}`;
|
|
100
|
+
const password = randomPassword();
|
|
101
|
+
const accountRes = await request("POST", `${base}/accounts`, { body: { address, password } });
|
|
102
|
+
const account = await parseJson(accountRes);
|
|
103
|
+
const tokenRes = await request("POST", `${base}/token`, { body: { address, password } });
|
|
104
|
+
const auth = await parseJson(tokenRes);
|
|
105
|
+
const inbox = {
|
|
106
|
+
provider: providerName,
|
|
107
|
+
address: account.address || address,
|
|
108
|
+
label: options?.label,
|
|
109
|
+
token: auth.token,
|
|
110
|
+
password,
|
|
111
|
+
accountId: account.id || auth.id,
|
|
112
|
+
createdAt: new Date().toISOString(),
|
|
113
|
+
};
|
|
114
|
+
return inbox;
|
|
115
|
+
},
|
|
116
|
+
async listMessages(inbox) {
|
|
117
|
+
if (!inbox.token)
|
|
118
|
+
throw new ProviderError(providerName, "Inbox is missing its API token");
|
|
119
|
+
const res = await request("GET", `${base}/messages?page=1`, { token: inbox.token });
|
|
120
|
+
const data = await parseCollection(res);
|
|
121
|
+
return data.map((m) => ({
|
|
122
|
+
id: m.id,
|
|
123
|
+
from: m.from?.address ?? "unknown",
|
|
124
|
+
fromName: m.from?.name,
|
|
125
|
+
subject: m.subject ?? "(no subject)",
|
|
126
|
+
intro: m.intro,
|
|
127
|
+
createdAt: m.createdAt,
|
|
128
|
+
}));
|
|
129
|
+
},
|
|
130
|
+
async readMessage(inbox, id) {
|
|
131
|
+
if (!inbox.token)
|
|
132
|
+
throw new ProviderError(providerName, "Inbox is missing its API token");
|
|
133
|
+
const res = await request("GET", `${base}/messages/${encodeURIComponent(id)}`, { token: inbox.token });
|
|
134
|
+
const m = await parseJson(res);
|
|
135
|
+
const html = m.html && m.html.length > 0 ? m.html.join("\n") : undefined;
|
|
136
|
+
const message = {
|
|
137
|
+
id: m.id,
|
|
138
|
+
from: m.from?.address ?? "unknown",
|
|
139
|
+
fromName: m.from?.name,
|
|
140
|
+
subject: m.subject ?? "(no subject)",
|
|
141
|
+
intro: m.intro,
|
|
142
|
+
createdAt: m.createdAt,
|
|
143
|
+
text: m.text,
|
|
144
|
+
html,
|
|
145
|
+
};
|
|
146
|
+
message.code = extractCode(message.text) ?? extractCode(message.html);
|
|
147
|
+
return message;
|
|
148
|
+
},
|
|
149
|
+
async destroyInbox(inbox) {
|
|
150
|
+
if (!inbox.token || !inbox.accountId)
|
|
151
|
+
return;
|
|
152
|
+
try {
|
|
153
|
+
await request("DELETE", `${base}/accounts/${encodeURIComponent(inbox.accountId)}`, {
|
|
154
|
+
token: inbox.token,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// best effort: local removal always happens regardless
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
};
|
|
77
162
|
}
|
|
78
|
-
export const mailTm = {
|
|
163
|
+
export const mailTm = createMailTmLikeProvider({
|
|
79
164
|
name: "mailtm",
|
|
165
|
+
base: "https://api.mail.tm",
|
|
166
|
+
host: "api.mail.tm",
|
|
80
167
|
description: "mail.tm — free disposable email, no API key required",
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const address = `${randomUser()}@${domain}`;
|
|
89
|
-
const password = randomPassword();
|
|
90
|
-
const accountRes = await request("POST", `${BASE}/accounts`, { body: { address, password } });
|
|
91
|
-
const account = await parseJson(accountRes, this.name);
|
|
92
|
-
const tokenRes = await request("POST", `${BASE}/token`, { body: { address, password } });
|
|
93
|
-
const auth = await parseJson(tokenRes, this.name);
|
|
94
|
-
const inbox = {
|
|
95
|
-
provider: this.name,
|
|
96
|
-
address: account.address || address,
|
|
97
|
-
label: options?.label,
|
|
98
|
-
token: auth.token,
|
|
99
|
-
password,
|
|
100
|
-
accountId: account.id || auth.id,
|
|
101
|
-
createdAt: new Date().toISOString(),
|
|
102
|
-
};
|
|
103
|
-
return inbox;
|
|
104
|
-
},
|
|
105
|
-
async listMessages(inbox) {
|
|
106
|
-
if (!inbox.token)
|
|
107
|
-
throw new ProviderError(this.name, "Inbox is missing its API token");
|
|
108
|
-
const res = await request("GET", `${BASE}/messages?page=1`, { token: inbox.token });
|
|
109
|
-
const data = await parseCollection(res, this.name);
|
|
110
|
-
return data.map((m) => ({
|
|
111
|
-
id: m.id,
|
|
112
|
-
from: m.from?.address ?? "unknown",
|
|
113
|
-
fromName: m.from?.name,
|
|
114
|
-
subject: m.subject ?? "(no subject)",
|
|
115
|
-
intro: m.intro,
|
|
116
|
-
createdAt: m.createdAt,
|
|
117
|
-
}));
|
|
118
|
-
},
|
|
119
|
-
async readMessage(inbox, id) {
|
|
120
|
-
if (!inbox.token)
|
|
121
|
-
throw new ProviderError(this.name, "Inbox is missing its API token");
|
|
122
|
-
const res = await request("GET", `${BASE}/messages/${encodeURIComponent(id)}`, { token: inbox.token });
|
|
123
|
-
const m = await parseJson(res, this.name);
|
|
124
|
-
const html = m.html && m.html.length > 0 ? m.html.join("\n") : undefined;
|
|
125
|
-
const message = {
|
|
126
|
-
id: m.id,
|
|
127
|
-
from: m.from?.address ?? "unknown",
|
|
128
|
-
fromName: m.from?.name,
|
|
129
|
-
subject: m.subject ?? "(no subject)",
|
|
130
|
-
intro: m.intro,
|
|
131
|
-
createdAt: m.createdAt,
|
|
132
|
-
text: m.text,
|
|
133
|
-
html,
|
|
134
|
-
};
|
|
135
|
-
message.code = extractCode(message.text) ?? extractCode(message.html);
|
|
136
|
-
return message;
|
|
137
|
-
},
|
|
138
|
-
async destroyInbox(inbox) {
|
|
139
|
-
if (!inbox.token || !inbox.accountId)
|
|
140
|
-
return;
|
|
141
|
-
try {
|
|
142
|
-
await request("DELETE", `${BASE}/accounts/${encodeURIComponent(inbox.accountId)}`, {
|
|
143
|
-
token: inbox.token,
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
catch {
|
|
147
|
-
// best effort: local removal always happens regardless
|
|
148
|
-
}
|
|
149
|
-
},
|
|
150
|
-
};
|
|
168
|
+
});
|
|
169
|
+
export const mailGw = createMailTmLikeProvider({
|
|
170
|
+
name: "mailgw",
|
|
171
|
+
base: "https://api.mail.gw",
|
|
172
|
+
host: "api.mail.gw",
|
|
173
|
+
description: "mail.gw — mail.tm-compatible API on independent infrastructure",
|
|
174
|
+
});
|
package/dist/core/state.js
CHANGED
|
@@ -61,9 +61,28 @@ export async function resolveInbox(address) {
|
|
|
61
61
|
}
|
|
62
62
|
async function writeState(state) {
|
|
63
63
|
const file = statePath();
|
|
64
|
+
const dir = path.dirname(file);
|
|
64
65
|
// The state file contains provider tokens — keep it private.
|
|
65
|
-
await fs.mkdir(
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
|
|
66
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
67
|
+
// Atomic save: write a sibling temp file, fsync it, then rename it over the
|
|
68
|
+
// real one. A crash mid-write can no longer truncate state.json and destroy
|
|
69
|
+
// saved inboxes — readers always see either the old file or the new one.
|
|
70
|
+
const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
|
|
71
|
+
const handle = await fs.open(tmp, "w", 0o600);
|
|
72
|
+
try {
|
|
73
|
+
await handle.writeFile(JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
74
|
+
// open()'s mode is filtered by umask — enforce 0600 before the rename.
|
|
75
|
+
await handle.chmod(0o600);
|
|
76
|
+
await handle.sync(); // flush to disk before it becomes the real file
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
await handle.close();
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
await fs.rename(tmp, file); // atomic on POSIX; replaces the target on Windows too
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
await fs.rm(tmp, { force: true }).catch(() => { });
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
69
88
|
}
|
package/dist/version.js
CHANGED
package/llms.txt
CHANGED
|
@@ -7,6 +7,21 @@ an MCP server, collects verification codes (OTP) from them, and deletes the
|
|
|
7
7
|
inboxes when done. It is designed agent-first: every CLI command supports
|
|
8
8
|
`--json`, exit codes are documented, and there are no interactive prompts.
|
|
9
9
|
|
|
10
|
+
## Site
|
|
11
|
+
|
|
12
|
+
- [Home](https://tossinbox.pages.dev/): overview, install options, comparison table
|
|
13
|
+
- [Quickstart](https://tossinbox.pages.dev/quickstart): first inbox in four commands
|
|
14
|
+
- [CLI reference](https://tossinbox.pages.dev/cli): all commands, flags, exit codes
|
|
15
|
+
- [Agents & MCP](https://tossinbox.pages.dev/agents): MCP server setup for AI agents
|
|
16
|
+
- [Examples](https://tossinbox.pages.dev/examples): copy-paste recipes (shell, CI, Node.js, MCP)
|
|
17
|
+
- [Guide](https://tossinbox.pages.dev/guide): providers, state, flags, exit codes, troubleshooting
|
|
18
|
+
- [FAQ](https://tossinbox.pages.dev/faq): privacy, providers, troubleshooting
|
|
19
|
+
- [Roadmap](https://tossinbox.pages.dev/roadmap): shipped and planned
|
|
20
|
+
- [Changelog](https://tossinbox.pages.dev/changelog): every change by version
|
|
21
|
+
- [Arabic home](https://tossinbox.pages.dev/ar/): الصفحة الرئيسية بالعربية
|
|
22
|
+
- [Arabic quickstart](https://tossinbox.pages.dev/ar/quickstart): البداية السريعة بالعربية
|
|
23
|
+
- [Source repository](https://github.com/mohamed-khairy-5i/tossinbox): code, issues, releases
|
|
24
|
+
|
|
10
25
|
## CLI (binary: `tossinbox`)
|
|
11
26
|
|
|
12
27
|
- `tossinbox spawn` — create a new disposable inbox (flags: `-p provider`, `-l label`)
|
|
@@ -37,9 +52,9 @@ Typical agent flow: `create_inbox` -> use the address in a signup form ->
|
|
|
37
52
|
## Installation
|
|
38
53
|
|
|
39
54
|
```bash
|
|
40
|
-
npm install -g
|
|
55
|
+
npm install -g tossinbox
|
|
41
56
|
# or run without installing:
|
|
42
|
-
npx
|
|
57
|
+
npx tossinbox@latest spawn
|
|
43
58
|
```
|
|
44
59
|
|
|
45
60
|
## Notes
|