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 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
  [![CI](https://github.com/mohamed-khairy-5i/tossinbox/actions/workflows/ci.yml/badge.svg)](https://github.com/mohamed-khairy-5i/tossinbox/actions/workflows/ci.yml)
10
+ [![npm](https://img.shields.io/npm/v/tossinbox?color=cb3837&label=npm)](https://www.npmjs.com/package/tossinbox)
11
+ [![npm downloads](https://img.shields.io/npm/dm/tossinbox?color=cb3837&label=downloads)](https://www.npmjs.com/package/tossinbox?activeTab=versions)
10
12
  ![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)
11
13
  ![Node](https://img.shields.io/badge/node-18%2B-34d399.svg)
12
14
  ![MCP](https://img.shields.io/badge/MCP-server-222a39.svg)
@@ -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 | varies | many wrap the dead 1secmail |
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
- - [ ] `mail.gw` provider (mail.tm-compatible API — small lift)
264
- - [ ] `tempmail.lol` provider (free API)
265
- - [ ] Provider failover: auto-switch when a provider is down
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
- const provider = providerOrExit(opts.provider);
87
- const inbox = await provider.createInbox({ label: opts.label });
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({ ok: true, inbox });
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({ ok: true, inbox: inbox.address, message: jsonMessage(message, true) });
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
+ }
@@ -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
- const p = getProvider(provider);
49
- const inbox = await p.createInbox({ label });
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
- return text({ ok: true, message: messageJson(message, false) });
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
@@ -1,3 +1 @@
1
- /** Single source of truth for the runtime version string.
2
- * Keep in sync with package.json — bump both on release. */
3
- export const VERSION = "0.1.4";
1
+ export const VERSION = "0.1.6";
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) and `guerrillamail`; no API keys required.
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tossinbox",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Disposable email inboxes for humans and AI agents. Spawn an inbox, wait for the OTP, toss it.",
5
5
  "type": "module",
6
6
  "license": "MIT",