tossinbox 0.1.4 → 0.1.6
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 +28 -7
- package/dist/cli.js +76 -8
- package/dist/core/failover.js +66 -0
- package/dist/core/index.js +2 -0
- package/dist/core/providers/mailtm.js +21 -0
- package/dist/core/providers/tempmailio.js +28 -0
- package/dist/core/providers/tempmailplus.js +36 -4
- package/dist/core/save.js +41 -0
- package/dist/mcp.js +33 -8
- package/dist/version.js +1 -3
- package/llms.txt +16 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
**Disposable email inboxes for humans and AI agents. Spawn a temporary inbox, wait for the OTP verification code, toss it.**
|
|
8
8
|
|
|
9
9
|
[](https://github.com/mohamed-khairy-5i/tossinbox/actions/workflows/ci.yml)
|
|
10
|
+
[](https://www.npmjs.com/package/tossinbox)
|
|
11
|
+
[](https://www.npmjs.com/package/tossinbox?activeTab=versions)
|
|
10
12
|

|
|
11
13
|

|
|
12
14
|

|
|
@@ -55,7 +57,7 @@ $ tossinbox toss
|
|
|
55
57
|
| Documented exit codes | 0–4 | none | no |
|
|
56
58
|
| MCP server for agents | yes, built in | no | no |
|
|
57
59
|
| Runs headless / in CI | yes | no | partial |
|
|
58
|
-
| Upstream alive | 7 providers, 4 stacks
|
|
60
|
+
| Upstream alive | 7 providers, 4 stacks, attachments on read | varies | many wrap the dead 1secmail |
|
|
59
61
|
| Ads, trackers, popups | none | the business model | none |
|
|
60
62
|
|
|
61
63
|
Checked September 2026. If a cell is wrong, open an issue and win the argument.
|
|
@@ -107,9 +109,9 @@ tossinbox wait --code --json
|
|
|
107
109
|
|
|
108
110
|
| Command | Description |
|
|
109
111
|
|---|---|
|
|
110
|
-
| `spawn` | Create a new disposable inbox (`-p provider`, `-l label`) |
|
|
112
|
+
| `spawn` | Create a new disposable inbox (`-p provider`, `-l label`). If the provider is down, another one is used automatically — `--no-failover` opts out |
|
|
111
113
|
| `list` | List messages (`-a address`) |
|
|
112
|
-
| `read <id>` | Read a full message, including any detected code |
|
|
114
|
+
| `read <id>` | Read a full message, including any detected code; lists attachments. `--html` prints the raw HTML body, `--save [dir]` downloads attachments + bodies to `<dir>/<message-id>/` |
|
|
113
115
|
| `wait` | Poll until a message arrives (`-f sender`, `-s subject`, `-c` extract code, `-t timeout` max 600s) |
|
|
114
116
|
| `watch` | Stream new messages until Ctrl-C — only new arrivals; `--json` = one JSON object per line (NDJSON) |
|
|
115
117
|
| `inboxes` | List locally saved inboxes |
|
|
@@ -155,7 +157,7 @@ TossInbox ships with an MCP server exposing four tools:
|
|
|
155
157
|
|---|---|
|
|
156
158
|
| `create_inbox` | Create a disposable inbox and return its address |
|
|
157
159
|
| `list_messages` | List messages in an inbox |
|
|
158
|
-
| `read_message` | Read a full message, including any detected code |
|
|
160
|
+
| `read_message` | Read a full message, including any detected code; reports attachment metadata and can save attachments + bodies to disk (`save_dir`) |
|
|
159
161
|
| `wait_for_code` | Poll until a message arrives and return its verification code |
|
|
160
162
|
|
|
161
163
|
### Claude Desktop / Cursor / any MCP client
|
|
@@ -226,6 +228,12 @@ machine-readable and kept up to date.
|
|
|
226
228
|
Adding a provider means implementing a small interface (`createInbox`,
|
|
227
229
|
`listMessages`, `readMessage`, optional `destroyInbox`) — PRs welcome.
|
|
228
230
|
|
|
231
|
+
**Attachments** (v0.1.6): full list + download on `mailtm`, `mailgw` and
|
|
232
|
+
`tempmailplus`; `tempmailio` shows them when its upstream returns them
|
|
233
|
+
(downloadable only if it exposes a URL); `tempmaillol`, `guerrillamail` and
|
|
234
|
+
`maildrop` do not expose attachments upstream — TossInbox tells you that
|
|
235
|
+
instead of guessing.
|
|
236
|
+
|
|
229
237
|
## FAQ
|
|
230
238
|
|
|
231
239
|
**Is it really free?**
|
|
@@ -239,6 +247,18 @@ ships no bulk-send or bulk-signup mode.
|
|
|
239
247
|
Yes, anywhere Node.js 18+ runs. `npx tossinbox@latest spawn`
|
|
240
248
|
works in PowerShell exactly the same.
|
|
241
249
|
|
|
250
|
+
**What if a provider is down?**
|
|
251
|
+
`spawn` fails over automatically: it retries the create against the remaining
|
|
252
|
+
providers and reports the switch (human mode prints a `⚠` warning and
|
|
253
|
+
`provider : mailtm (failover from mailgw)`; `--json` returns a `failover`
|
|
254
|
+
object). Use `--no-failover` if you need the chosen provider or nothing.
|
|
255
|
+
|
|
256
|
+
**Can I download attachments?**
|
|
257
|
+
Yes on `mailtm`, `mailgw` and `tempmailplus`; `tempmailio` shows them when its
|
|
258
|
+
upstream exposes them; the other three providers have no attachments upstream.
|
|
259
|
+
`tossinbox read <id> --save` writes them (plus the HTML/plain-text bodies) to
|
|
260
|
+
`./tossinbox-attachments/<message-id>/`.
|
|
261
|
+
|
|
242
262
|
**A site blocked my disposable address. What now?**
|
|
243
263
|
Some sites blocklist known disposable domains. Try the other provider:
|
|
244
264
|
`tossinbox spawn -p guerrillamail`. If both are blocked, the site wins that
|
|
@@ -260,9 +280,10 @@ round.
|
|
|
260
280
|
- [x] Homebrew tap: `brew install mohamed-khairy-5i/tap/tossinbox`
|
|
261
281
|
- [x] Project website at [tossinbox.pages.dev](https://tossinbox.pages.dev/)
|
|
262
282
|
- [x] Publish `tossinbox` + `tossinbox-mcp` to the [npm registry](https://www.npmjs.com/package/tossinbox)
|
|
263
|
-
- [
|
|
264
|
-
- [
|
|
265
|
-
- [
|
|
283
|
+
- [x] `mail.gw` provider (v0.1.3)
|
|
284
|
+
- [x] Four more providers: `tempmail.lol`, `temp-mail.io`, `tempmail.plus`, `maildrop.cc` (v0.1.4)
|
|
285
|
+
- [x] Provider failover: auto-switch when a provider is down (v0.1.5)
|
|
286
|
+
- [x] Attachments & HTML bodies: `read --save` / `--html` (v0.1.6)
|
|
266
287
|
- [ ] Homebrew core formula (after community adoption)
|
|
267
288
|
|
|
268
289
|
## Documentation
|
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, sleep, htmlToText, ProviderError, } from "./core/index.js";
|
|
3
|
+
import { DEFAULT_PROVIDER, createInboxWithFailover, getProvider, listProviders, listSavedInboxes, removeInbox, clearInboxes, resolveInbox, saveInbox, saveMessageContent, 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
|
|
@@ -58,8 +58,26 @@ function jsonMessage(message, includeHtml = false) {
|
|
|
58
58
|
code: message.code,
|
|
59
59
|
text: message.text ?? (message.html ? htmlToText(message.html) : undefined),
|
|
60
60
|
html: includeHtml ? message.html : undefined,
|
|
61
|
+
...(message.attachments
|
|
62
|
+
? {
|
|
63
|
+
attachments: message.attachments.map((a) => ({
|
|
64
|
+
filename: a.filename,
|
|
65
|
+
size: a.size,
|
|
66
|
+
contentType: a.contentType,
|
|
67
|
+
})),
|
|
68
|
+
}
|
|
69
|
+
: {}),
|
|
61
70
|
};
|
|
62
71
|
}
|
|
72
|
+
function formatSize(bytes) {
|
|
73
|
+
if (bytes === undefined)
|
|
74
|
+
return "?";
|
|
75
|
+
if (bytes < 1024)
|
|
76
|
+
return `${bytes} B`;
|
|
77
|
+
if (bytes < 1024 * 1024)
|
|
78
|
+
return `${(bytes / 1024).toFixed(1)} kB`;
|
|
79
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
80
|
+
}
|
|
63
81
|
function printMessageHuman(message, withBody) {
|
|
64
82
|
console.log(`✔ ${message.subject}`);
|
|
65
83
|
console.log(` from : ${message.fromName ? `${message.fromName} <${message.from}>` : message.from}`);
|
|
@@ -67,6 +85,12 @@ function printMessageHuman(message, withBody) {
|
|
|
67
85
|
console.log(` date : ${message.createdAt}`);
|
|
68
86
|
if (message.code)
|
|
69
87
|
console.log(` code : ${message.code}`);
|
|
88
|
+
if (message.attachments && message.attachments.length > 0) {
|
|
89
|
+
console.log(` attachments (${message.attachments.length}):`);
|
|
90
|
+
for (const a of message.attachments) {
|
|
91
|
+
console.log(` - ${a.filename} (${formatSize(a.size)}${a.contentType ? `, ${a.contentType}` : ""})`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
70
94
|
if (withBody) {
|
|
71
95
|
const body = message.text ?? (message.html ? htmlToText(message.html) : "");
|
|
72
96
|
if (body)
|
|
@@ -78,20 +102,32 @@ function printMessageHuman(message, withBody) {
|
|
|
78
102
|
/* ------------------------------------------------------------------ */
|
|
79
103
|
program
|
|
80
104
|
.command("spawn")
|
|
81
|
-
.description("Create a new disposable inbox")
|
|
105
|
+
.description("Create a new disposable inbox (automatically falls back to another provider when the chosen one is down)")
|
|
82
106
|
.option("-p, --provider <name>", "email provider (see: providers)", DEFAULT_PROVIDER)
|
|
83
107
|
.option("-l, --label <label>", "optional label to identify this inbox")
|
|
108
|
+
.option("--no-failover", "fail if the chosen provider is down instead of falling back to another one")
|
|
84
109
|
.action(async (opts) => {
|
|
85
110
|
try {
|
|
86
|
-
|
|
87
|
-
const inbox = await
|
|
111
|
+
providerOrExit(opts.provider); // unknown name = usage error before any network call
|
|
112
|
+
const { inbox, switched, warnings } = await createInboxWithFailover({
|
|
113
|
+
requested: opts.provider,
|
|
114
|
+
label: opts.label,
|
|
115
|
+
failover: opts.failover,
|
|
116
|
+
});
|
|
88
117
|
await saveInbox(inbox);
|
|
89
118
|
if (jsonMode()) {
|
|
90
|
-
out({
|
|
119
|
+
out({
|
|
120
|
+
ok: true,
|
|
121
|
+
inbox,
|
|
122
|
+
...(switched ? { failover: { requested: opts.provider, used: inbox.provider } } : {}),
|
|
123
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
124
|
+
});
|
|
91
125
|
return;
|
|
92
126
|
}
|
|
127
|
+
for (const warning of warnings)
|
|
128
|
+
console.error(`⚠ ${warning}`);
|
|
93
129
|
console.log(`✔ Inbox ready : ${inbox.address}`);
|
|
94
|
-
console.log(` provider : ${inbox.provider}`);
|
|
130
|
+
console.log(` provider : ${inbox.provider}${switched ? ` (failover from ${opts.provider})` : ""}`);
|
|
95
131
|
if (inbox.label)
|
|
96
132
|
console.log(` label : ${inbox.label}`);
|
|
97
133
|
console.log(` state file : ${statePath()}`);
|
|
@@ -132,8 +168,10 @@ program
|
|
|
132
168
|
});
|
|
133
169
|
program
|
|
134
170
|
.command("read <id>")
|
|
135
|
-
.description("Read a full message by id")
|
|
171
|
+
.description("Read a full message by id (list attachments, optionally save them and the HTML body)")
|
|
136
172
|
.option("-a, --address <address>", "inbox address (defaults to the most recent inbox)")
|
|
173
|
+
.option("--html", "print the raw HTML body instead of the plain-text version")
|
|
174
|
+
.option("--save [dir]", "save attachments + body.html/body.txt into <dir>/<message-id>/ (default dir: ./tossinbox-attachments)")
|
|
137
175
|
.action(async (id, opts) => {
|
|
138
176
|
try {
|
|
139
177
|
const inbox = await resolveInbox(opts.address);
|
|
@@ -141,11 +179,38 @@ program
|
|
|
141
179
|
fail(new Error("No saved inbox found. Run: tossinbox spawn"), EXIT_NOT_FOUND);
|
|
142
180
|
const provider = providerOrExit(inbox.provider);
|
|
143
181
|
const message = await provider.readMessage(inbox, id);
|
|
182
|
+
let saved;
|
|
183
|
+
if (opts.save !== undefined) {
|
|
184
|
+
const dir = typeof opts.save === "string" && opts.save.length > 0 ? opts.save : "./tossinbox-attachments";
|
|
185
|
+
try {
|
|
186
|
+
saved = await saveMessageContent(provider, inbox, message, dir);
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
fail(err);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
144
192
|
if (jsonMode()) {
|
|
145
|
-
out({
|
|
193
|
+
out({
|
|
194
|
+
ok: true,
|
|
195
|
+
inbox: inbox.address,
|
|
196
|
+
message: jsonMessage(message, true),
|
|
197
|
+
...(saved ? { saved } : {}),
|
|
198
|
+
});
|
|
146
199
|
return;
|
|
147
200
|
}
|
|
148
201
|
printMessageHuman(message, true);
|
|
202
|
+
if (opts.html) {
|
|
203
|
+
if (message.html) {
|
|
204
|
+
console.log(`\n${message.html}\n`);
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
console.error("⚠ this message has no HTML body — showing the plain-text version above");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (saved) {
|
|
211
|
+
for (const f of saved)
|
|
212
|
+
console.log(` ✔ saved ${f.filename} → ${f.path}`);
|
|
213
|
+
}
|
|
149
214
|
}
|
|
150
215
|
catch (err) {
|
|
151
216
|
fail(err, err instanceof ProviderError && err.status === 404 ? EXIT_NOT_FOUND : EXIT_ERROR);
|
|
@@ -302,6 +367,9 @@ program
|
|
|
302
367
|
createdAt: summary.createdAt,
|
|
303
368
|
code: message?.code,
|
|
304
369
|
text: message?.text ?? (message?.html ? htmlToText(message.html) : undefined),
|
|
370
|
+
...(message?.attachments && message.attachments.length > 0
|
|
371
|
+
? { attachments: message.attachments.map((a) => ({ filename: a.filename, size: a.size })) }
|
|
372
|
+
: {}),
|
|
305
373
|
};
|
|
306
374
|
console.log(JSON.stringify(event));
|
|
307
375
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { providers } from "./index.js";
|
|
2
|
+
import { ProviderError } from "./types.js";
|
|
3
|
+
/** A failure that automatic failover is allowed to recover from: network-level
|
|
4
|
+
* errors (no status) and server-side trouble (5xx) or throttling (429).
|
|
5
|
+
* A plain 4xx is a real request problem — switching providers cannot fix it. */
|
|
6
|
+
function isTransient(err) {
|
|
7
|
+
if (!(err instanceof ProviderError))
|
|
8
|
+
return true; // network-level → transient
|
|
9
|
+
const { status } = err;
|
|
10
|
+
return status === undefined || status === 429 || status >= 500;
|
|
11
|
+
}
|
|
12
|
+
function describe(err) {
|
|
13
|
+
return err instanceof Error ? err.message : String(err);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Create an inbox, falling back to other providers when the requested one is
|
|
17
|
+
* down. The requested provider is always tried first; the remaining providers
|
|
18
|
+
* follow registration order (best default first). Every failed attempt is
|
|
19
|
+
* recorded as a warning so humans and agents can see exactly what happened.
|
|
20
|
+
*/
|
|
21
|
+
export async function createInboxWithFailover(options = {}) {
|
|
22
|
+
const failover = options.failover !== false;
|
|
23
|
+
const requestedName = options.requested ?? Object.keys(providers)[0];
|
|
24
|
+
const requested = providers[requestedName];
|
|
25
|
+
if (!requested) {
|
|
26
|
+
const known = Object.keys(providers).join(", ");
|
|
27
|
+
throw new Error(`Unknown provider "${requestedName}". Available providers: ${known}`);
|
|
28
|
+
}
|
|
29
|
+
// Registration order, requested provider first.
|
|
30
|
+
const candidates = [
|
|
31
|
+
requested,
|
|
32
|
+
...Object.values(providers).filter((p) => p.name !== requested.name),
|
|
33
|
+
];
|
|
34
|
+
const warnings = [];
|
|
35
|
+
let firstError;
|
|
36
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
37
|
+
const candidate = candidates[i];
|
|
38
|
+
try {
|
|
39
|
+
const inbox = await candidate.createInbox({ label: options.label });
|
|
40
|
+
return {
|
|
41
|
+
inbox,
|
|
42
|
+
switched: i > 0,
|
|
43
|
+
warnings,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
firstError ??= err;
|
|
48
|
+
warnings.push(`${candidate.name}: ${describe(err)}`);
|
|
49
|
+
// A non-transient 4xx on the REQUESTED provider is a real request
|
|
50
|
+
// problem (bad payload, blocked domain…) — retrying others would just
|
|
51
|
+
// mask it. Only when the user did not explicitly pick a provider do we
|
|
52
|
+
// fall through anyway, because they never asked for this one by name.
|
|
53
|
+
if (!isTransient(err) && options.requested)
|
|
54
|
+
throw err;
|
|
55
|
+
if (!failover)
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Every candidate failed. Re-throw the requested provider's original error
|
|
60
|
+
// (it names the provider the user actually asked for) with a failover note.
|
|
61
|
+
const base = describe(firstError);
|
|
62
|
+
const others = candidates.length - 1;
|
|
63
|
+
throw new ProviderError(requestedName, others > 0
|
|
64
|
+
? `${base} — failover also tried ${others} other provider(s) without success`
|
|
65
|
+
: base);
|
|
66
|
+
}
|
package/dist/core/index.js
CHANGED
|
@@ -8,6 +8,8 @@ export * from "./types.js";
|
|
|
8
8
|
export { extractCode, htmlToText } from "./otp.js";
|
|
9
9
|
export * from "./state.js";
|
|
10
10
|
export { waitForMessage, sleep } from "./wait.js";
|
|
11
|
+
export { createInboxWithFailover } from "./failover.js";
|
|
12
|
+
export { saveMessageContent, safeSegment } from "./save.js";
|
|
11
13
|
export const providers = {
|
|
12
14
|
[mailTm.name]: mailTm,
|
|
13
15
|
[mailGw.name]: mailGw,
|
|
@@ -133,6 +133,13 @@ function createMailTmLikeProvider(config) {
|
|
|
133
133
|
const res = await request("GET", `${base}/messages/${encodeURIComponent(id)}`, { token: inbox.token });
|
|
134
134
|
const m = await parseJson(res);
|
|
135
135
|
const html = m.html && m.html.length > 0 ? m.html.join("\n") : undefined;
|
|
136
|
+
const attachments = (m.attachments ?? []).map((a) => ({
|
|
137
|
+
id: a.id,
|
|
138
|
+
filename: a.filename || "attachment.bin",
|
|
139
|
+
contentType: a.contentType,
|
|
140
|
+
size: a.size,
|
|
141
|
+
contentId: a.disposition === "inline" ? a.id : undefined,
|
|
142
|
+
}));
|
|
136
143
|
const message = {
|
|
137
144
|
id: m.id,
|
|
138
145
|
from: m.from?.address ?? "unknown",
|
|
@@ -142,10 +149,24 @@ function createMailTmLikeProvider(config) {
|
|
|
142
149
|
createdAt: m.createdAt,
|
|
143
150
|
text: m.text,
|
|
144
151
|
html,
|
|
152
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
145
153
|
};
|
|
146
154
|
message.code = extractCode(message.text) ?? extractCode(message.html);
|
|
147
155
|
return message;
|
|
148
156
|
},
|
|
157
|
+
async downloadAttachment(inbox, messageId, attachment) {
|
|
158
|
+
if (!inbox.token)
|
|
159
|
+
throw new ProviderError(providerName, "Inbox is missing its API token");
|
|
160
|
+
if (!attachment.id) {
|
|
161
|
+
throw new ProviderError(providerName, `Attachment "${attachment.filename}" has no id to download`);
|
|
162
|
+
}
|
|
163
|
+
const url = `${base}/messages/${encodeURIComponent(messageId)}/attachment/${encodeURIComponent(attachment.id)}`;
|
|
164
|
+
const res = await request("GET", url, { token: inbox.token });
|
|
165
|
+
if (!res.ok) {
|
|
166
|
+
throw new ProviderError(providerName, `HTTP ${res.status} while downloading "${attachment.filename}"`, res.status);
|
|
167
|
+
}
|
|
168
|
+
return Buffer.from(await res.arrayBuffer());
|
|
169
|
+
},
|
|
149
170
|
async destroyInbox(inbox) {
|
|
150
171
|
if (!inbox.token || !inbox.accountId)
|
|
151
172
|
return;
|
|
@@ -54,6 +54,16 @@ async function fetchMessages(address) {
|
|
|
54
54
|
throw err;
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
|
+
/** The v3 API attachment objects vary between deployments; parse defensively. */
|
|
58
|
+
function toAttachment(a) {
|
|
59
|
+
return {
|
|
60
|
+
id: a.id !== undefined ? String(a.id) : undefined,
|
|
61
|
+
filename: a.filename ?? a.name ?? "attachment.bin",
|
|
62
|
+
contentType: a.contentType ?? a.content_type ?? a.type,
|
|
63
|
+
size: a.size,
|
|
64
|
+
url: a.url,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
57
67
|
export const tempmailIo = {
|
|
58
68
|
name: "tempmailio",
|
|
59
69
|
description: "temp-mail.io — disposable email with 10+ rotating domains, no API key required",
|
|
@@ -97,10 +107,28 @@ export const tempmailIo = {
|
|
|
97
107
|
createdAt: email.created_at,
|
|
98
108
|
text: email.body_text,
|
|
99
109
|
html: email.body_html,
|
|
110
|
+
...((email.attachments ?? []).length > 0
|
|
111
|
+
? { attachments: (email.attachments ?? []).map(toAttachment) }
|
|
112
|
+
: {}),
|
|
100
113
|
};
|
|
101
114
|
message.code = extractCode(message.text) ?? extractCode(message.html);
|
|
102
115
|
return message;
|
|
103
116
|
},
|
|
117
|
+
async downloadAttachment(_inbox, _messageId, attachment) {
|
|
118
|
+
if (!attachment.url) {
|
|
119
|
+
throw new ProviderError("tempmailio", `temp-mail.io did not expose a download URL for "${attachment.filename}" — open the message in the provider UI to retrieve it`);
|
|
120
|
+
}
|
|
121
|
+
const res = await fetch(attachment.url, {
|
|
122
|
+
headers: { Accept: "*/*", "User-Agent": `tossinbox/${VERSION}` },
|
|
123
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
124
|
+
}).catch((err) => {
|
|
125
|
+
throw networkError(err, "tempmailio", HOST, REQUEST_TIMEOUT_MS);
|
|
126
|
+
});
|
|
127
|
+
if (!res.ok) {
|
|
128
|
+
throw new ProviderError("tempmailio", `HTTP ${res.status} while downloading "${attachment.filename}"`, res.status);
|
|
129
|
+
}
|
|
130
|
+
return Buffer.from(await res.arrayBuffer());
|
|
131
|
+
},
|
|
104
132
|
async destroyInbox(inbox) {
|
|
105
133
|
if (!inbox.token)
|
|
106
134
|
return;
|
|
@@ -20,6 +20,11 @@ function randomUser(length = 12) {
|
|
|
20
20
|
function timeToIso(ts) {
|
|
21
21
|
if (!ts)
|
|
22
22
|
return undefined;
|
|
23
|
+
if (typeof ts === "string") {
|
|
24
|
+
// Some endpoints return "YYYY-MM-DD HH:mm:ss" strings instead of epochs
|
|
25
|
+
const d = new Date(ts.includes("T") ? ts : ts.replace(" ", "T") + "Z");
|
|
26
|
+
return isNaN(d.getTime()) ? undefined : d.toISOString();
|
|
27
|
+
}
|
|
23
28
|
// Guard against seconds vs milliseconds epochs
|
|
24
29
|
return new Date(ts < 1e12 ? ts * 1000 : ts).toISOString();
|
|
25
30
|
}
|
|
@@ -75,8 +80,8 @@ export const tempmailPlus = {
|
|
|
75
80
|
async listMessages(inbox) {
|
|
76
81
|
const data = await callJson("GET", `/api/mails/?email=${encodeURIComponent(inbox.address)}&first_id=0`);
|
|
77
82
|
return (data.mail_list ?? []).map((m) => ({
|
|
78
|
-
id: String(m.id),
|
|
79
|
-
from: m.from ?? "unknown",
|
|
83
|
+
id: String(m.mail_id ?? m.id),
|
|
84
|
+
from: m.from_mail ?? m.from ?? "unknown",
|
|
80
85
|
fromName: m.from_name,
|
|
81
86
|
subject: m.subject ?? "(no subject)",
|
|
82
87
|
createdAt: timeToIso(m.time),
|
|
@@ -87,18 +92,45 @@ export const tempmailPlus = {
|
|
|
87
92
|
if (!data.result) {
|
|
88
93
|
throw new ProviderError("tempmailplus", "Message not found — re-list messages to see what is currently in the inbox");
|
|
89
94
|
}
|
|
95
|
+
const attachments = (data.attachments ?? []).map((a) => ({
|
|
96
|
+
id: a.attachment_id !== undefined ? String(a.attachment_id) : undefined,
|
|
97
|
+
filename: a.name || "attachment.bin",
|
|
98
|
+
size: a.size,
|
|
99
|
+
contentId: a.content_id || undefined,
|
|
100
|
+
}));
|
|
90
101
|
const message = {
|
|
91
102
|
id,
|
|
92
|
-
from: data.from ?? "unknown",
|
|
103
|
+
from: data.from_mail ?? data.from ?? "unknown",
|
|
93
104
|
fromName: data.from_name,
|
|
94
105
|
subject: data.subject ?? "(no subject)",
|
|
95
|
-
createdAt: timeToIso(data.time),
|
|
106
|
+
createdAt: timeToIso(data.time) ?? data.date,
|
|
96
107
|
text: data.text,
|
|
97
108
|
html: data.html,
|
|
109
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
98
110
|
};
|
|
99
111
|
message.code = extractCode(message.text) ?? extractCode(message.html);
|
|
100
112
|
return message;
|
|
101
113
|
},
|
|
114
|
+
async downloadAttachment(inbox, messageId, attachment) {
|
|
115
|
+
if (!attachment.id) {
|
|
116
|
+
throw new ProviderError("tempmailplus", `Attachment "${attachment.filename}" has no id to download`);
|
|
117
|
+
}
|
|
118
|
+
// Download endpoint as used by the tempmail.plus web client:
|
|
119
|
+
// /api/mails/{mailId}/attachments/{attachment_id}?email={address}&epin={pin?}
|
|
120
|
+
const url = `${BASE}/api/mails/${encodeURIComponent(messageId)}` +
|
|
121
|
+
`/attachments/${encodeURIComponent(attachment.id)}` +
|
|
122
|
+
`?email=${encodeURIComponent(inbox.address)}&epin=${encodeURIComponent(inbox.session ?? "")}`;
|
|
123
|
+
const res = await fetch(url, {
|
|
124
|
+
headers: { Accept: "*/*", "User-Agent": `tossinbox/${VERSION}` },
|
|
125
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
126
|
+
}).catch((err) => {
|
|
127
|
+
throw networkError(err, "tempmailplus", HOST, REQUEST_TIMEOUT_MS);
|
|
128
|
+
});
|
|
129
|
+
if (!res.ok) {
|
|
130
|
+
throw new ProviderError("tempmailplus", `HTTP ${res.status} while downloading "${attachment.filename}"`, res.status);
|
|
131
|
+
}
|
|
132
|
+
return Buffer.from(await res.arrayBuffer());
|
|
133
|
+
},
|
|
102
134
|
async destroyInbox(inbox) {
|
|
103
135
|
try {
|
|
104
136
|
const data = await callJson("GET", `/api/mails/?email=${encodeURIComponent(inbox.address)}&first_id=0`);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
/** Filesystem-safe single path segment: strips separators, traversal and
|
|
4
|
+
* control characters, collapses blanks. */
|
|
5
|
+
export function safeSegment(name, fallback) {
|
|
6
|
+
const cleaned = name
|
|
7
|
+
.replace(/[^A-Za-z0-9._ ()\[\]-]/g, "_")
|
|
8
|
+
.replace(/\.{2,}/g, "_")
|
|
9
|
+
.replace(/^_+|_+$/g, "")
|
|
10
|
+
.trim();
|
|
11
|
+
return cleaned.length > 0 && cleaned !== "." && cleaned !== ".." ? cleaned.slice(0, 120) : fallback;
|
|
12
|
+
}
|
|
13
|
+
/** Save everything a message carries to disk: every attachment (when the
|
|
14
|
+
* provider supports downloads) plus body.html / body.txt when present.
|
|
15
|
+
* Files land in `<dir>/<message-id>/`. Shared by the CLI and the MCP server. */
|
|
16
|
+
export async function saveMessageContent(provider, inbox, message, dir) {
|
|
17
|
+
const messageDir = path.join(path.resolve(dir), safeSegment(message.id, "message"));
|
|
18
|
+
fs.mkdirSync(messageDir, { recursive: true });
|
|
19
|
+
const saved = [];
|
|
20
|
+
for (const attachment of message.attachments ?? []) {
|
|
21
|
+
if (typeof provider.downloadAttachment !== "function") {
|
|
22
|
+
throw new Error(`provider "${provider.name}" does not support attachment downloads — try another provider`);
|
|
23
|
+
}
|
|
24
|
+
const bytes = await provider.downloadAttachment(inbox, message.id, attachment);
|
|
25
|
+
const filename = safeSegment(attachment.filename, "attachment.bin");
|
|
26
|
+
const target = path.join(messageDir, filename);
|
|
27
|
+
fs.writeFileSync(target, bytes);
|
|
28
|
+
saved.push({ kind: "attachment", filename, path: target, size: bytes.length });
|
|
29
|
+
}
|
|
30
|
+
if (message.html !== undefined) {
|
|
31
|
+
const target = path.join(messageDir, "body.html");
|
|
32
|
+
fs.writeFileSync(target, message.html, "utf8");
|
|
33
|
+
saved.push({ kind: "body.html", filename: "body.html", path: target, size: Buffer.byteLength(message.html) });
|
|
34
|
+
}
|
|
35
|
+
if (message.text !== undefined) {
|
|
36
|
+
const target = path.join(messageDir, "body.txt");
|
|
37
|
+
fs.writeFileSync(target, message.text, "utf8");
|
|
38
|
+
saved.push({ kind: "body.txt", filename: "body.txt", path: target, size: Buffer.byteLength(message.text) });
|
|
39
|
+
}
|
|
40
|
+
return saved;
|
|
41
|
+
}
|
package/dist/mcp.js
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
6
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
7
|
import { z } from "zod";
|
|
8
|
-
import { DEFAULT_PROVIDER, getProvider, resolveInbox, saveInbox, waitForMessage, htmlToText, } from "./core/index.js";
|
|
8
|
+
import { DEFAULT_PROVIDER, createInboxWithFailover, getProvider, resolveInbox, saveInbox, saveMessageContent, waitForMessage, htmlToText, } from "./core/index.js";
|
|
9
9
|
import { VERSION } from "./version.js";
|
|
10
10
|
function text(result, isError = false) {
|
|
11
11
|
return {
|
|
@@ -23,6 +23,15 @@ function messageJson(message, includeHtml = false) {
|
|
|
23
23
|
code: message.code,
|
|
24
24
|
text: message.text ?? (message.html ? htmlToText(message.html) : undefined),
|
|
25
25
|
html: includeHtml ? message.html : undefined,
|
|
26
|
+
...(message.attachments
|
|
27
|
+
? {
|
|
28
|
+
attachments: message.attachments.map((a) => ({
|
|
29
|
+
filename: a.filename,
|
|
30
|
+
size: a.size,
|
|
31
|
+
contentType: a.contentType,
|
|
32
|
+
})),
|
|
33
|
+
}
|
|
34
|
+
: {}),
|
|
26
35
|
};
|
|
27
36
|
}
|
|
28
37
|
async function requireInbox(address) {
|
|
@@ -40,17 +49,24 @@ export async function startMcpServer() {
|
|
|
40
49
|
title: "Create a disposable inbox",
|
|
41
50
|
description: "Create a brand new disposable email inbox. The inbox is saved locally so the other tools can use it. Returns the full email address to use in sign-up forms.",
|
|
42
51
|
inputSchema: {
|
|
43
|
-
provider: z.string().optional().describe(`Provider name (default: "${DEFAULT_PROVIDER}", see the providers list)`),
|
|
52
|
+
provider: z.string().optional().describe(`Provider name (default: "${DEFAULT_PROVIDER}", see the providers list). If the provider is down, another one is used automatically unless no_failover is set`),
|
|
44
53
|
label: z.string().optional().describe("Optional label to identify this inbox"),
|
|
54
|
+
no_failover: z.boolean().optional().describe("Fail when the chosen provider is down instead of falling back to another one"),
|
|
45
55
|
},
|
|
46
|
-
}, async ({ provider, label }) => {
|
|
56
|
+
}, async ({ provider, label, no_failover }) => {
|
|
47
57
|
try {
|
|
48
|
-
|
|
49
|
-
const inbox = await
|
|
58
|
+
getProvider(provider); // unknown name = clean error before any network call
|
|
59
|
+
const { inbox, switched, warnings } = await createInboxWithFailover({
|
|
60
|
+
requested: provider,
|
|
61
|
+
label,
|
|
62
|
+
failover: !no_failover,
|
|
63
|
+
});
|
|
50
64
|
await saveInbox(inbox);
|
|
51
65
|
return text({
|
|
52
66
|
ok: true,
|
|
53
67
|
inbox: { address: inbox.address, provider: inbox.provider, label: inbox.label },
|
|
68
|
+
...(switched ? { failover: { requested: provider ?? DEFAULT_PROVIDER, used: inbox.provider } } : {}),
|
|
69
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
54
70
|
hint: `Use address "${inbox.address}" in the sign-up form, then call wait_for_code after submitting it.`,
|
|
55
71
|
});
|
|
56
72
|
}
|
|
@@ -79,19 +95,28 @@ export async function startMcpServer() {
|
|
|
79
95
|
});
|
|
80
96
|
server.registerTool("read_message", {
|
|
81
97
|
title: "Read a message",
|
|
82
|
-
description: "Read the full body of a message by id, including any verification code detected in it.",
|
|
98
|
+
description: "Read the full body of a message by id, including any verification code detected in it. Lists attachment metadata; pass save_dir to also save the attachments and the HTML body to disk (where the provider supports downloads).",
|
|
83
99
|
inputSchema: {
|
|
84
100
|
id: z.string().describe("Message id (from list_messages)"),
|
|
85
101
|
address: z.string().optional().describe("Inbox address; defaults to the most recent inbox"),
|
|
102
|
+
save_dir: z.string().optional().describe('Directory to save attachments and body.html/body.txt into, written to <save_dir>/<message-id>/ (e.g. "/tmp")'),
|
|
86
103
|
},
|
|
87
|
-
}, async ({ id, address }) => {
|
|
104
|
+
}, async ({ id, address, save_dir }) => {
|
|
88
105
|
try {
|
|
89
106
|
const inbox = await requireInbox(address);
|
|
90
107
|
if (!inbox)
|
|
91
108
|
return text({ ok: false, error: "No saved inbox found. Call create_inbox first." }, true);
|
|
92
109
|
const p = getProvider(inbox.provider);
|
|
93
110
|
const message = await p.readMessage(inbox, id);
|
|
94
|
-
|
|
111
|
+
let saved;
|
|
112
|
+
if (save_dir) {
|
|
113
|
+
saved = await saveMessageContent(p, inbox, message, save_dir);
|
|
114
|
+
}
|
|
115
|
+
return text({
|
|
116
|
+
ok: true,
|
|
117
|
+
message: messageJson(message, false),
|
|
118
|
+
...(saved ? { saved } : {}),
|
|
119
|
+
});
|
|
95
120
|
}
|
|
96
121
|
catch (err) {
|
|
97
122
|
return text({ ok: false, error: err instanceof Error ? err.message : String(err) }, true);
|
package/dist/version.js
CHANGED
package/llms.txt
CHANGED
|
@@ -24,11 +24,16 @@ inboxes when done. It is designed agent-first: every CLI command supports
|
|
|
24
24
|
|
|
25
25
|
## CLI (binary: `tossinbox`)
|
|
26
26
|
|
|
27
|
-
- `tossinbox spawn` — create a new disposable inbox (flags: `-p provider`, `-l label`)
|
|
27
|
+
- `tossinbox spawn` — create a new disposable inbox (flags: `-p provider`, `-l label`);
|
|
28
|
+
if the provider is down, another one is used automatically (`--no-failover` opts out)
|
|
28
29
|
- `tossinbox list` — list messages in an inbox (flag: `-a address`)
|
|
29
|
-
- `tossinbox read <id>` — read a full message by id
|
|
30
|
+
- `tossinbox read <id>` — read a full message by id; `--save [dir]` saves every attachment plus the
|
|
31
|
+
HTML/plain-text bodies to `<dir>/<message-id>/` (default dir `./tossinbox-attachments`),
|
|
32
|
+
`--html` prints the raw HTML body
|
|
30
33
|
- `tossinbox wait --code` — poll until a message arrives and print its verification code
|
|
31
34
|
(flags: `-f sender`, `-s subject`, `-t timeout`, `-i interval`)
|
|
35
|
+
- `tossinbox watch` — stream new messages as they arrive until Ctrl-C (NDJSON with `--json`; events
|
|
36
|
+
include attachment names when a new message has them)
|
|
32
37
|
- `tossinbox inboxes` — list locally saved inboxes
|
|
33
38
|
- `tossinbox toss` — delete an inbox server-side and wipe local state (`--all` for all)
|
|
34
39
|
- `tossinbox providers` — list providers (default: `mailtm`; also `mailgw`, `guerrillamail`, `tempmaillol`, `tempmailio`, `tempmailplus`, `maildrop`)
|
|
@@ -42,7 +47,8 @@ Stdio MCP server exposing four tools:
|
|
|
42
47
|
|
|
43
48
|
- `create_inbox` — create a disposable inbox, returns its email address
|
|
44
49
|
- `list_messages` — list messages in an inbox
|
|
45
|
-
- `read_message` — read a full message including any detected code
|
|
50
|
+
- `read_message` — read a full message including any detected code; reports attachment metadata and
|
|
51
|
+
accepts an optional `save_dir` input that saves attachments + bodies to `<save_dir>/<message-id>/`
|
|
46
52
|
- `wait_for_code` — poll until a message arrives and return its verification code
|
|
47
53
|
|
|
48
54
|
Typical agent flow: `create_inbox` -> use the address in a signup form ->
|
|
@@ -59,7 +65,13 @@ npx tossinbox@latest spawn
|
|
|
59
65
|
|
|
60
66
|
## Notes
|
|
61
67
|
|
|
62
|
-
- Providers: `mailtm` (default)
|
|
68
|
+
- Providers: `mailtm` (default), `mailgw`, `guerrillamail`, `tempmaillol`,
|
|
69
|
+
`tempmailio`, `tempmailplus`, `maildrop`; no API keys required. When a
|
|
70
|
+
provider is down, `spawn` fails over to another one automatically.
|
|
71
|
+
- Attachments: full list + download on `mailtm`, `mailgw` and `tempmailplus`;
|
|
72
|
+
`tempmailio` shows them when its upstream returns them (download only if it
|
|
73
|
+
exposes a URL); `tempmaillol`, `guerrillamail` and `maildrop` have no
|
|
74
|
+
attachment support upstream.
|
|
63
75
|
- Local state is stored in `~/.tossinbox/state.json` (override with the
|
|
64
76
|
`TOSSINBOX_STATE` environment variable) with 0600 permissions.
|
|
65
77
|
- TossInbox intentionally has no bulk mode; use it for privacy and testing and
|