import { randomUUID } from "node:crypto"; import { mkdir, readFile, readdir, rename, rm, rmdir, writeFile, } from "node:fs/promises"; import path from "node:path"; import { decodePeerRecord, type PeerRecordV2 } from "./peer-record.ts"; import type { DeliveryRecord, MessageRecord, PresenceRecord, } from "./types.ts"; const SAFE_ID = /^[A-Za-z0-9._-]+$/; const STORE_DIRECTORIES = ["peers", "presence", "messages", "mailboxes"] as const; const STORE_IGNORE_CONTENT = "# Pi Mail runtime data\n*\n"; const MANAGED_IGNORE_CONTENTS = new Set([ STORE_IGNORE_CONTENT, "# Pi Mail runtime data\n*\n!.gitignore\n", "# Pi Mail runtime data\n*\n.gitignore\n", ]); function errorCode(error: unknown): string | undefined { return typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code) : undefined; } function assertSafeId(value: string, label = "id"): void { if (!SAFE_ID.test(value)) { throw new Error(`Invalid ${label}`); } } async function readJson(file: string): Promise { try { return JSON.parse(await readFile(file, "utf8")) as T; } catch (error) { if (errorCode(error) === "ENOENT") return null; throw error; } } async function atomicWriteJson(file: string, value: unknown): Promise { await mkdir(path.dirname(file), { recursive: true }); const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`; await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600, }); try { await rename(tmp, file); } catch (error) { // rename() is atomic on the local filesystems we target, but Windows does // not consistently replace an existing destination. The fallback keeps // the update local to one delivery/presence record instead of introducing // a cross-process lock protocol. if (errorCode(error) === "EEXIST" || errorCode(error) === "EPERM") { await rm(file, { force: true }); await rename(tmp, file); return; } await rm(tmp, { force: true }); throw error; } } async function listJson(dir: string): Promise { let names: string[]; try { names = await readdir(dir); } catch (error) { if (errorCode(error) === "ENOENT") return []; throw error; } const values: T[] = []; for (const name of names) { if (!name.endsWith(".json")) continue; const value = await readJson(path.join(dir, name)); if (value) values.push(value); } return values; } export class FsMailStore { readonly root: string; constructor(root: string) { this.root = path.resolve(root); } async init(): Promise { for (const dir of STORE_DIRECTORIES) { await mkdir(path.join(this.root, dir), { recursive: true }); } await this.ensureIgnoreFile(); } /** Remove a store that contains no mail data, without recursively deleting anything. */ async removeIfEmpty(): Promise { for (const dir of STORE_DIRECTORIES) { if (!await this.removeDirectoryIfEmpty(path.join(this.root, dir))) return false; } let entries: string[]; try { entries = await readdir(this.root); } catch (error) { if (errorCode(error) !== "ENOENT") return false; await this.removeEmptyPiParent(); return true; } if (entries.some((entry) => entry !== ".gitignore")) return false; const ignoreFile = path.join(this.root, ".gitignore"); if (entries.includes(".gitignore")) { let content: string; try { content = await readFile(ignoreFile, "utf8"); } catch { return false; } if (!MANAGED_IGNORE_CONTENTS.has(content)) return false; try { await rm(ignoreFile); } catch { return false; } } try { await rmdir(this.root); } catch { // Another runtime may have populated the store after the emptiness check. await this.ensureIgnoreFile().catch(() => {}); return false; } await this.removeEmptyPiParent(); return true; } async getPeer(peerId: string): Promise { const file = this.peerFile(peerId); const value = await readJson(file); return value === null ? null : decodePeerRecord(value, file); } async putPeer(peer: PeerRecordV2): Promise { await atomicWriteJson(this.peerFile(peer.id), peer); } async listPeers(): Promise { const dir = path.join(this.root, "peers"); let names: string[]; try { names = await readdir(dir); } catch (error) { if (errorCode(error) === "ENOENT") return []; throw error; } const peers: PeerRecordV2[] = []; for (const name of names) { if (!name.endsWith(".json")) continue; const file = path.join(dir, name); const value = await readJson(file); if (value !== null) peers.push(decodePeerRecord(value, file)); } return peers; } async removePeer(peerId: string): Promise { await rm(this.peerFile(peerId), { force: true }); } async putPresence(presence: PresenceRecord): Promise { await atomicWriteJson( this.presenceFile(presence.sessionId, presence.runtimeId), presence, ); } async removePresence(sessionId: string, runtimeId: string): Promise { await rm(this.presenceFile(sessionId, runtimeId), { force: true }); try { await rmdir(path.join(this.root, "presence", sessionId)); } catch { // A non-empty directory means another runtime for this session is still // present and therefore must remain discoverable. } } async removeSessionPresence(sessionId: string): Promise { assertSafeId(sessionId, "session id"); await rm(path.join(this.root, "presence", sessionId), { recursive: true, force: true }); } async listPresence(): Promise { const base = path.join(this.root, "presence"); let sessionDirs; try { sessionDirs = await readdir(base, { withFileTypes: true }); } catch (error) { if (errorCode(error) === "ENOENT") return []; throw error; } const values: PresenceRecord[] = []; for (const entry of sessionDirs) { if (!entry.isDirectory()) continue; values.push(...await listJson(path.join(base, entry.name))); } return values; } async tryCreateMessage(message: MessageRecord): Promise { const file = this.messageFile(message.id); await mkdir(path.dirname(file), { recursive: true }); // Exclusive creation is the collision check. A separate existence check // would leave a race window between the check and this write. try { await writeFile(file, `${JSON.stringify(message, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600, }); return true; } catch (error) { if (errorCode(error) === "EEXIST") return false; throw error; } } async getMessage(messageId: string): Promise { return readJson(this.messageFile(messageId)); } async listMessages(): Promise { return listJson(path.join(this.root, "messages")); } async removeMessage(messageId: string): Promise { await rm(this.messageFile(messageId), { force: true }); } async putDelivery(delivery: DeliveryRecord): Promise { await atomicWriteJson( this.deliveryFile(delivery.recipientId, delivery.messageId), delivery, ); } async getDelivery(recipientId: string, messageId: string): Promise { return readJson(this.deliveryFile(recipientId, messageId)); } async listDeliveries(recipientId: string): Promise { assertSafeId(recipientId, "recipient id"); return listJson(path.join(this.root, "mailboxes", recipientId)); } async listDeliveryIds(recipientId: string): Promise { assertSafeId(recipientId, "recipient id"); const dir = path.join(this.root, "mailboxes", recipientId); let names: string[]; try { names = await readdir(dir); } catch (error) { if (errorCode(error) === "ENOENT") return []; throw error; } return names .filter((name) => name.endsWith(".json")) .map((name) => name.slice(0, -".json".length)); } async removeMailbox(recipientId: string): Promise { assertSafeId(recipientId, "recipient id"); await rm(path.join(this.root, "mailboxes", recipientId), { recursive: true, force: true }); } async removeMailboxIfEmpty(recipientId: string): Promise { assertSafeId(recipientId, "recipient id"); return this.removeDirectoryIfEmpty(path.join(this.root, "mailboxes", recipientId)); } async removeSessionPresenceIfEmpty(sessionId: string): Promise { assertSafeId(sessionId, "session id"); return this.removeDirectoryIfEmpty(path.join(this.root, "presence", sessionId)); } async updateDelivery( recipientId: string, messageId: string, update: Partial>, ): Promise { const current = await this.getDelivery(recipientId, messageId); if (!current) return null; const next = { ...current, ...update }; await this.putDelivery(next); return next; } private async removeDirectoryIfEmpty(directory: string): Promise { try { await rmdir(directory); return true; } catch (error) { return errorCode(error) === "ENOENT"; } } private async removeEmptyPiParent(): Promise { const parent = path.dirname(this.root); if (path.basename(this.root) === "mails" && path.basename(parent) === ".pi") { await rmdir(parent).catch(() => {}); } } private async ensureIgnoreFile(): Promise { const ignoreFile = path.join(this.root, ".gitignore"); try { await writeFile(ignoreFile, STORE_IGNORE_CONTENT, { encoding: "utf8", flag: "wx", mode: 0o644, }); } catch (error) { if (errorCode(error) !== "EEXIST") throw error; } } private peerFile(peerId: string): string { assertSafeId(peerId, "peer id"); return path.join(this.root, "peers", `${peerId}.json`); } private presenceFile(sessionId: string, runtimeId: string): string { assertSafeId(sessionId, "session id"); assertSafeId(runtimeId, "runtime id"); return path.join(this.root, "presence", sessionId, `${runtimeId}.json`); } private messageFile(messageId: string): string { assertSafeId(messageId, "message id"); return path.join(this.root, "messages", `${messageId}.json`); } private deliveryFile(recipientId: string, messageId: string): string { assertSafeId(recipientId, "recipient id"); assertSafeId(messageId, "message id"); return path.join(this.root, "mailboxes", recipientId, `${messageId}.json`); } }