Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> # 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)
Expand Down
2 changes: 1 addition & 1 deletion cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ npx @smails/cli inbox # list messages
npx @smails/cli read <id> # read a message (full id or short prefix)
npx @smails/cli delete <id> # 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:
Expand Down
36 changes: 23 additions & 13 deletions cli/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>;
}

async getDomains(): Promise<string[]> {
Expand Down Expand Up @@ -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<void> {
await this.request(`/api/mailbox/messages/${id}`, { method: "DELETE" });
await this.request(`/api/mailbox/messages/${encodeURIComponent(id)}`, { method: "DELETE" });
}
}
17 changes: 7 additions & 10 deletions cli/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
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

Usage: smails <command> [options]

Commands:
create [--domain <d>] 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 <id> Read a message (full id or short prefix)
delete <id> Delete a message (full id or short prefix)
Expand Down Expand Up @@ -56,16 +57,12 @@ async function resolveMessageId(api: SmailsAPI, idOrPrefix: string): Promise<str
}

async function create(args: string[]) {
const existing = loadConfig();
const isNew = args.includes("--new");
if (existing && !isNew) {
console.log(`You already have a mailbox: ${existing.address}`);
console.log("Use `smails create --new` to create a new one.");
const result = await createMailbox(flag(args, "--domain"), args.includes("--force"));
if (!result.created) {
console.log(`You already have a mailbox: ${result.existing}`);
console.log("Use `smails create --force` to create a new one.");
return;
}
const api = new SmailsAPI();
const result = await api.createMailbox(flag(args, "--domain"));
saveConfig({ address: result.address, token: result.token });
console.log(`Mailbox created: ${result.address}`);
}

Expand Down
14 changes: 12 additions & 2 deletions cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,21 @@ interface Config {
}

export function loadConfig(): Config | null {
let data: string;
try {
data = readFileSync(CONFIG_PATH, "utf-8");
} catch (err) {
// No mailbox yet — that's fine. Any other read error (permissions, etc.)
// must surface so we never treat an existing config as absent.
if ((err as { code?: string }).code === "ENOENT") return null;
throw new Error(`Cannot read ${CONFIG_PATH}: ${(err as Error).message}`);
}
try {
const data = readFileSync(CONFIG_PATH, "utf-8");
return JSON.parse(data) as Config;
} catch {
return null;
throw new Error(
`Config file ${CONFIG_PATH} is corrupt. Remove it (or run \`smails create --force\`) to start over.`,
);
}
}

Expand Down
28 changes: 28 additions & 0 deletions cli/src/mailbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { SmailsAPI } from "./api.js";
import { loadConfig, saveConfig } from "./config.js";

export type CreateResult =
| { created: true; address: string }
| { created: false; existing: string };

/**
* Create a mailbox and persist it to the local config. Shared by the CLI
* `create` command and the MCP `create_mailbox` tool so the recovery semantics
* live in one place.
*
* When `force` is false an existing saved mailbox short-circuits with
* `created: false`. `force` also skips loadConfig entirely so it can recover even
* when the saved config is unreadable/corrupt (loadConfig throws in that case).
*/
export async function createMailbox(
domain: string | undefined,
force: boolean,
): Promise<CreateResult> {
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 };
}
12 changes: 5 additions & 7 deletions cli/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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}` }] };
},
);
Expand Down
112 changes: 112 additions & 0 deletions frontend/app/components/content.tsx
Original file line number Diff line number Diff line change
@@ -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 <script> for the given @graph. Nodes are built with the
* helpers in ~/lib/seo (breadcrumbList, faqPage, …) plus a per-page article node.
*/
export function JsonLd({ graph }: { graph: unknown[] }) {
const data = { "@context": "https://schema.org", "@graph": graph };
return (
// biome-ignore lint/security/noDangerouslySetInnerHtml: trusted JSON-LD built from static data
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }} />
);
}

/** Top-of-page breadcrumb: smails / <label>. */
export function Breadcrumb({ label }: { label: string }) {
return (
<nav aria-label="Breadcrumb" className="pt-10 text-xs text-muted-foreground">
<Link to="/" className="inline-flex items-center gap-1 hover:text-foreground">
<ArrowLeft className="size-3" />
smails
</Link>
<span className="mx-2">/</span>
<span className="text-foreground">{label}</span>
</nav>
);
}

/** Bulleted list with a success check mark per item. */
export function CheckList({ items }: { items: string[] }) {
return (
<ul className="mt-6 space-y-3">
{items.map((item) => (
<li key={item} className="flex gap-3 text-sm sm:text-base">
<Check className="mt-0.5 size-4 shrink-0 text-success" />
<span className="text-muted-foreground">{item}</span>
</li>
))}
</ul>
);
}

/** Numbered (1, 2, 3 …) ordered list of steps. */
export function NumberedSteps({ items }: { items: string[] }) {
return (
<ol className="mt-6 space-y-4">
{items.map((step, i) => (
<li key={step} className="flex gap-4">
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted font-mono text-xs font-medium">
{i + 1}
</span>
<p className="pt-1 text-sm text-muted-foreground sm:text-base">{step}</p>
</li>
))}
</ol>
);
}

/** The "FAQ" section with an accordion of question/answer items. */
export function FaqSection({ items }: { items: FaqItem[] }) {
return (
<section className="pt-16 sm:pt-20">
<h2 className="text-2xl font-semibold tracking-tight">FAQ</h2>
<Card className="mt-6 gap-0 p-0">
<Accordion multiple={false} className="w-full">
{items.map((item) => (
<AccordionItem key={item.q} value={item.q} className="px-5">
<AccordionTrigger>{item.q}</AccordionTrigger>
<AccordionContent className="text-muted-foreground">{item.a}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</Card>
</section>
);
}

/** Closing call-to-action card with the fixed Open-smails / GitHub buttons. */
export function CtaSection({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="pt-16 pb-8 sm:pt-20">
<Card className="flex flex-col items-center gap-4 p-8 text-center">
<h2 className="text-xl font-semibold tracking-tight">{title}</h2>
<p className="max-w-md text-sm text-muted-foreground">{children}</p>
<div className="flex flex-wrap justify-center gap-3">
<Button render={<Link to="/" />}>Open smails</Button>
<Button
variant="outline"
render={
<a href={GITHUB_URL} target="_blank" rel="noreferrer">
GitHub
<ArrowUpRight />
</a>
}
/>
</div>
</Card>
</section>
);
}
40 changes: 27 additions & 13 deletions frontend/app/hooks/use-websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@ import { useCallback, useEffect, useRef, useState } from "react";

export type WsStatus = "connecting" | "connected" | "disconnected";

/**
* Close a socket we're intentionally replacing/disposing, detaching its handlers
* first so its async `onclose` can't mutate shared state (status, ping timer,
* reconnect) for the socket that replaced it — e.g. when the mailbox changes.
*/
function detachAndClose(ws: WebSocket | null) {
if (!ws) return;
ws.onopen = null;
ws.onmessage = null;
ws.onclose = null;
ws.onerror = null;
ws.close();
}

interface UseWebSocketOptions {
url: string | null;
onMessage?: (data: unknown) => void;
Expand Down Expand Up @@ -47,7 +61,7 @@ export function useWebSocket({
const currentUrl = urlRef.current;
if (!currentUrl || disposedRef.current) return;

wsRef.current?.close();
detachAndClose(wsRef.current);
setStatus("connecting");

const ws = new WebSocket(currentUrl);
Expand Down Expand Up @@ -88,28 +102,28 @@ export function useWebSocket({
wsRef.current = ws;
}, [maxRetries, startPing, stopPing]);

const disconnect = useCallback(() => {
// Reset the whole socket lifecycle (timers, ping, handlers, ref). Used by both
// disconnect() and the effect cleanup so the teardown steps can't drift apart.
const teardown = useCallback(() => {
disposedRef.current = true;
clearTimeout(timerRef.current);
stopPing();
disposedRef.current = true;
wsRef.current?.close();
detachAndClose(wsRef.current);
wsRef.current = null;
setStatus("disconnected");
}, [stopPing]);

const disconnect = useCallback(() => {
teardown();
setStatus("disconnected");
}, [teardown]);

// biome-ignore lint/correctness/useExhaustiveDependencies: `url` must stay so the effect re-runs and reconnects when the URL changes
useEffect(() => {
disposedRef.current = false;
retriesRef.current = 0;
connect();
return () => {
disposedRef.current = true;
clearTimeout(timerRef.current);
stopPing();
wsRef.current?.close();
wsRef.current = null;
};
}, [url, connect, stopPing]);
return teardown;
}, [url, connect, teardown]);

return { status, reconnect: connect, disconnect };
}
Loading
Loading