A Val.town serverless service providing a temporary inbox for content before Obsidian vault processing. This is a buffer layer between input sources (email/HTTP) and processing clients (MCP/scripts).
Key Principle: Simple, stateless inbox. Complex processing happens in clients, not here.
Critical Context:
https://esm.sh/packagehttps://esm.town/v/std/libraryStandard Library Available:
sqlite - SQLite databaseblob - Binary blob storageemail - Send/receive emailsInput Sources → Handlers → Services → Storage → Clients
↓ ↓ ↓ ↓ ↓
Email emailHandler InboxService SQLite MCP Server
HTTP API → httpHandler AttachService Blob Custom Scripts
Handlers (entry points for Val.town):
emailHandler.ts - Email trigger val, processes incoming emailshttpHandler.ts - HTTP val, exports app.fetch for API endpointscleanupCron.ts - Cron val, runs daily to delete old processed recordsServices (data layer):
server/InboxService.ts - CRUD operations on inbox records in SQLiteserver/AttachmentService.ts - Store/retrieve binary files in blob storageserver/auth.ts - Bearer token auth & email sender allowlistCore App:
server/app.ts - Hono HTTP application with all routesserver/db.ts - Drizzle ORM schema definitionClient Integration:
mcp/server.ts - MCP server setup (runs locally, not on Val.town)mcp/tools/ - MCP tool implementationsapiClient.ts - HTTP client using Hono's RPC clientlocalMcpServer.ts - Entry point for local MCP server| File | Val Type | Purpose | Key Functions |
|---|---|---|---|
emailHandler.ts | Email Trigger | Receives emails, saves to inbox | emailHandler(email) |
httpHandler.ts | HTTP | API endpoint, exports app | export default app.fetch |
cleanupCron.ts | Cron | Daily cleanup of old records | cleanup() |
| File | Purpose | Exports |
|---|---|---|
server/app.ts | Hono HTTP app definition | app: Hono, AppType |
server/InboxService.ts | Inbox record persistence | InboxService.{save, markProcessed, retrieveUnprocessed, getById, hardDeleteProcessedRecords} |
server/AttachmentService.ts | Blob storage operations | AttachmentService.{save, get, delete} |
server/auth.ts | Authentication logic | bearerAuthMiddleware, isAllowedSender |
server/db.ts | Database schema | db, inboxRecords table, types |
| File | Purpose |
|---|---|
mcp/server.ts | MCP server setup, registers tools |
mcp/tools/readInbox.ts | Tool: List unprocessed records |
mcp/tools/addRecord.ts | Tool: Create new record |
mcp/tools/markRecordProcessed.ts | Tool: Mark as processed |
mcp/tools/getRecordAttachments.ts | Tool: List record attachments |
mcp/tools/downloadRecordAttachment.ts | Tool: Download attachment file |
mcp/tools/index.ts | Tool registration orchestration |
localMcpServer.ts | Entry point for MCP stdio transport |
apiClient.ts | HTTP client for remote API |
Location: server/InboxService.ts
{
raw: string, // Full text content (email body, etc.)
summary?: string, // Optional summary
from?: string, // Source identifier (email, "manual", etc.)
subject?: string, // Subject line or title
date?: Date, // When received (coerced to Date)
attachmentBlobIds: string[] // Array of blob storage keys
}
Validation: Use inboxRecordSchema (Zod) for validation
Middleware: validateInboxRecord for Hono routes
Table: inbox_records (defined in server/db.ts)
CREATE TABLE inbox_records (
id TEXT PRIMARY KEY, -- UUID
record TEXT NOT NULL, -- JSON-serialized InboxRecord
created_at TEXT DEFAULT current_timestamp,
processed_at TEXT -- NULL until marked processed
);
ORM: Drizzle ORM with LibSQL driver
Access: Via db export from server/db.ts
Keys:
{uuid} - Binary file data (ArrayBuffer){uuid}:meta - JSON metadata: {filename, contentType, size}Interface: StoredAttachment in server/AttachmentService.ts
All routes except GET / require Bearer token authentication via bearerAuthMiddleware.
| Method | Path | Middleware | Handler | Returns |
|---|---|---|---|---|
GET | / | None | Health check | "Hi mom" |
GET | /api/inbox | Bearer auth | List unprocessed | InboxRecordWithId[] |
POST | /api/record | Bearer auth + validation | Create record | {key: string} (201) |
PATCH | /api/record/:id/processed | Bearer auth | Mark processed | {key, processed: true} or 404 |
GET | /api/record/:recordId/attachments | Bearer auth | List attachments | {recordId, attachments: [...]} |
GET | /api/record/:recordId/attachments/:blobId | Bearer auth | Download file | Binary Response with headers |
Ingestion: Item arrives via email or HTTP POST
emailHandler.ts → validates sender → savesPOST /api/record → validates schema → savesStorage:
InboxService.save() creates UUID, saves JSON to SQLiteAttachmentService.save() stores binary + metadata to blobprocessed_at is NULLRetrieval:
GET /api/inbox → returns items where processed_at IS NULLGET /api/record/:id/attachmentsGET /api/record/:id/attachments/:blobIdProcessing:
Completion:
PATCH /api/record/:id/processedprocessed_at = current_timestampCleanup:
cleanupCron.tsprocessed_at <= date('now', '-30 days')AttachmentService.delete()Required in Val.town secrets:
INBOX_API_TOKEN - Bearer token for HTTP API authenticationINCOMING_EMAIL_ALLOWLIST - Comma-separated email addressesRequired for MCP client (local):
INBOX_API_URL - URL to deployed httpHandler valINBOX_API_TOKEN - Same token as aboveLocation: server/auth.ts
Middleware: bearerAuthMiddleware
Authorization: Bearer {token} headerINBOX_API_TOKEN env varLocation: server/auth.ts
Function: isAllowedSender(email: Email)
From: headerINCOMING_EMAIL_ALLOWLIST env varSteps:
inboxRecordSchema in server/InboxService.tsserver/db.ts:
// DON'T use ALTER TABLE - Val.town limitations
export const inboxRecords = sqliteTable("inbox_records_2", { ... });
Rationale: Changing table name avoids migration complexity in serverless env.
Steps:
server/app.ts:
.get("/api/new-endpoint", bearerAuthMiddleware, async (c) => {
// handler logic
})
README.md API referenceSteps:
mcp/tools/newTool.ts:
import type { McpServer } from "npm:@modelcontextprotocol/sdk/server/mcp.js";
import type { Client } from "../../apiClient.ts";
export function registerNewTool(server: McpServer, client: Client) {
server.tool("tool-name", "description", { /* schema */ }, async (params) => {
// Call client API
const response = await client.api.endpoint.$get();
// Return result
});
}
mcp/tools/index.ts:
import { registerNewTool } from "./newTool.ts";
export function registerAllTools(server: McpServer, client: Client) {
// ... existing
registerNewTool(server, client);
}
Steps:
webhookHandler.ts)InboxRecord formatInboxService.save(record)AttachmentService.save(file)Principle: Fail-fast with context
Example:
// DON'T
try {
const record = await InboxService.save(data);
} catch (err) {
console.error(err);
throw err; // Useless catch
}
// DO
const record = await InboxService.save(data); // Let it throw
const objects with methodsNo automated tests - manual testing via Val.town web interface
Email Testing:
GET /api/inboxHTTP Testing:
# Create record curl -X POST https://your-val.web.val.run/api/record \ -H "Authorization: Bearer TOKEN" \ -H "Content-Type: application/json" \ -d '{"raw": "test", "attachmentBlobIds": []}' # List unprocessed curl https://your-val.web.val.run/api/inbox \ -H "Authorization: Bearer TOKEN" # Mark processed curl -X PATCH https://your-val.web.val.run/api/record/{id}/processed \ -H "Authorization: Bearer TOKEN"
MCP Testing:
~/Library/Logs/Claude/mcp*.logFocus Areas:
Current Limitations:
Potential Improvements:
Initial Setup:
INBOX_API_TOKEN, INCOMING_EMAIL_ALLOWLISTemailHandler.ts as email trigger valhttpHandler.ts as HTTP valcleanupCron.ts as cron val (daily schedule)Local MCP Setup:
.env with INBOX_API_URL and INBOX_API_TOKENVerification:
Redirects:
Response.redirect() - doesn't worknew Response(null, {status: 302, headers: {Location: url}})Imports:
https://esm.sh/ for npm packageshttps://esm.town/v/std/libraryhttps://esm.town/v/username/valnameState:
sqlite and blob from stdlibDebugging:
console.log() appears in val logsMCP Server runs locally, calls remote API:
apiClient.ts with Hono RPC clienthc<AppType>(url)Available Tools:
inbox_read → GET /api/inboxinbox_add_record → POST /api/recordinbox_mark_processed → PATCH /api/record/:id/processedinbox_get_attachments → GET /api/record/:id/attachmentsinbox_download_attachment → GET /api/record/:id/attachments/:blobIdDirect HTTP API:
Workflow:
/api/inbox for new itemsWhy separate MCP server from Val.town?
Why not store processed items separately?
processed_at is efficientWhy 30-day retention?
InboxService.hardDeleteProcessedRecords)Why JSON-serialized records in SQLite?
Why Hono instead of native Deno HTTP?
hc<AppType>server/InboxService.ts:
export const inboxRecordSchema = z.object({
// ... existing fields
newField: z.string().optional(),
});
Update handlers if needed (emailHandler.ts, etc.)
Redeploy vals
server/InboxService.ts in hardDeleteProcessedRecords:
sql`${inboxRecords.processed_at} <= date('now', '-60 days')` // Change 30 to 60
See "Adding MCP Tools" section above.
# Check Claude Desktop logs tail -f ~/Library/Logs/Claude/mcp*.log # Test MCP server directly deno run --allow-net --allow-env localMcpServer.ts # Then send MCP protocol messages via stdin
Call from Val.town REPL or add endpoint:
await InboxService.hardDeleteProcessedRecords();
When working on this codebase:
Most common tasks:
server/app.ts with authmcp/tools/, register in index.ts