diff --git a/README.md b/README.md index 0ca6a2a..191b319 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ npx @smails/cli create # create a mailbox (token saved to ~/.smails) npx @smails/cli inbox # list messages npx @smails/cli read # read a message (id prefix is enough) npx @smails/cli whoami # show the current address -npx @smails/cli create --new # replace with a fresh mailbox +npx @smails/cli create --force # replace with a fresh mailbox ``` ### MCP (for AI agents) diff --git a/cli/README.md b/cli/README.md index 74f8fdb..bca5d85 100644 --- a/cli/README.md +++ b/cli/README.md @@ -14,7 +14,7 @@ npx @smails/cli inbox # list messages npx @smails/cli read # read a message (full id or short prefix) npx @smails/cli delete # delete a message npx @smails/cli whoami # show the current address -npx @smails/cli create --new # replace with a fresh mailbox +npx @smails/cli create --force # replace with a fresh mailbox ``` Install globally for a shorter command: diff --git a/cli/src/api.ts b/cli/src/api.ts index 109f944..dcbbf28 100644 --- a/cli/src/api.ts +++ b/cli/src/api.ts @@ -23,29 +23,39 @@ export class SmailsAPI { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15000); - let res: Response; try { - res = await fetch(`${this.baseUrl}${path}`, { + const res = await fetch(`${this.baseUrl}${path}`, { ...options, headers, signal: controller.signal, }); + // Read the body inside the try so the 15s deadline also covers a stalled + // response body, not just the headers. + if (!res.ok) { + const body = (await res.json().catch((e) => { + // Let an abort during the body read fall through to the timeout + // mapping below instead of being downgraded to the status text. + if (e instanceof Error && e.name === "AbortError") throw e; + return { error: res.statusText }; + })) as { + error?: string; + }; + throw new Error(body.error || `HTTP ${res.status}`); + } + return (await res.json()) as T; } catch (err) { if (err instanceof Error && err.name === "AbortError") { throw new Error(`Request timed out after 15s (${this.baseUrl})`); } - throw new Error(`Network error: cannot reach ${this.baseUrl}`); + // fetch() rejects with a TypeError on network failures (DNS, refused, …); + // our own HTTP errors above are plain Errors and re-throw unchanged. + if (err instanceof TypeError) { + throw new Error(`Network error: cannot reach ${this.baseUrl}`); + } + throw err; } finally { clearTimeout(timeout); } - - if (!res.ok) { - const body = (await res.json().catch(() => ({ error: res.statusText }))) as { - error?: string; - }; - throw new Error(body.error || `HTTP ${res.status}`); - } - return res.json() as Promise; } async getDomains(): Promise { @@ -84,10 +94,10 @@ export class SmailsAPI { text: string | null; attachments: unknown[]; }> { - return this.request(`/api/mailbox/messages/${id}`); + return this.request(`/api/mailbox/messages/${encodeURIComponent(id)}`); } async deleteMessage(id: string): Promise { - await this.request(`/api/mailbox/messages/${id}`, { method: "DELETE" }); + await this.request(`/api/mailbox/messages/${encodeURIComponent(id)}`, { method: "DELETE" }); } } diff --git a/cli/src/cli.ts b/cli/src/cli.ts index c09e4d1..6e5be4a 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -1,5 +1,6 @@ import { SmailsAPI } from "./api.js"; -import { loadConfig, saveConfig } from "./config.js"; +import { loadConfig } from "./config.js"; +import { createMailbox } from "./mailbox.js"; const HELP = `smails — disposable email for humans and agents @@ -7,7 +8,7 @@ Usage: smails [options] Commands: create [--domain ] Create a new mailbox (token saved to ~/.smails) - create --new Replace the current mailbox with a fresh one + create --force Replace the current mailbox with a fresh one inbox List messages read Read a message (full id or short prefix) delete Delete a message (full id or short prefix) @@ -56,16 +57,12 @@ async function resolveMessageId(api: SmailsAPI, idOrPrefix: string): Promise { + if (!force) { + const existing = loadConfig(); + if (existing) return { created: false, existing: existing.address }; + } + const result = await new SmailsAPI().createMailbox(domain); + saveConfig({ address: result.address, token: result.token }); + return { created: true, address: result.address }; +} diff --git a/cli/src/mcp.ts b/cli/src/mcp.ts index e3fa593..24fb571 100644 --- a/cli/src/mcp.ts +++ b/cli/src/mcp.ts @@ -3,7 +3,8 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { z } from "zod"; import pkg from "../package.json" with { type: "json" }; import { SmailsAPI } from "./api.js"; -import { loadConfig, saveConfig } from "./config.js"; +import { loadConfig } from "./config.js"; +import { createMailbox } from "./mailbox.js"; export async function runMCP() { const server = new McpServer({ @@ -27,20 +28,17 @@ export async function runMCP() { force: z.boolean().optional().describe("Set to true to replace an existing mailbox"), }, async ({ domain, force }) => { - const existing = loadConfig(); - if (existing && !force) { + const result = await createMailbox(domain, force ?? false); + if (!result.created) { return { content: [ { type: "text", - text: `A mailbox already exists: ${existing.address}. Pass force=true to replace it.`, + text: `A mailbox already exists: ${result.existing}. Pass force=true to replace it.`, }, ], }; } - const api = new SmailsAPI(); - const result = await api.createMailbox(domain); - saveConfig({ address: result.address, token: result.token }); return { content: [{ type: "text", text: `Mailbox created: ${result.address}` }] }; }, ); diff --git a/frontend/app/components/content.tsx b/frontend/app/components/content.tsx new file mode 100644 index 0000000..2553b79 --- /dev/null +++ b/frontend/app/components/content.tsx @@ -0,0 +1,112 @@ +import { ArrowLeft, ArrowUpRight, Check } from "lucide-react"; +import type { ReactNode } from "react"; +import { Link } from "react-router"; +import { GITHUB_URL } from "~/components/site-chrome"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "~/components/ui/accordion"; +import { Button } from "~/components/ui/button"; +import { Card } from "~/components/ui/card"; +import type { FaqItem } from "~/lib/seo"; + +/** + * A schema.org JSON-LD