import type { Buffer } from "node:buffer"; import * as net from "node:net"; import type { Event, Filter } from "@nostr/tools"; import { getPublicKey, nip19 } from "@nostr/tools"; import type { NPub, NSec } from "@nostr/tools/nip19"; import { type ImapCommandParameters, parseImapCommand } from "./imapParsing.ts"; import { handleUidFetch } from "./handleUidFetch.ts"; import { findEventIdForUid, getFlags, logMessage, markMessageAsFlagged, markMessageAsRead, sendSocketMessage, } from "./utils.ts"; import { countingRelay, mailingLists, queryingRelay } from "./main.ts"; export interface ImapSession { npub: NPub | null; selectedMailingList: string | null; } function handleLogin( session: ImapSession, socket: net.Socket, tag: string, npubString: ImapCommandParameters, nsecString: ImapCommandParameters, ) { if (typeof npubString !== "string" || typeof nsecString !== "string") { sendSocketMessage(socket, `${tag} NO LOGIN failed\r\n`); return; } if (!nsecString.startsWith("nsec1")) { sendSocketMessage(socket, `${tag} NO LOGIN failed\r\n`); return; } const sk = nip19.decode<"nsec">(nsecString as NSec); if (!sk?.data) { sendSocketMessage(socket, `${tag} NO LOGIN failed\r\n`); return; } const pkFromSk = getPublicKey(sk.data); const npub = nip19.npubEncode(pkFromSk); if (npubString === npub) { session.npub = npub; sendSocketMessage(socket, `${tag} OK LOGIN completed\r\n`); } else { sendSocketMessage(socket, `${tag} NO LOGIN failed\r\n`); } } async function handleSelect( session: ImapSession, socket: net.Socket, tag: string, mailbox: ImapCommandParameters, ) { if (!session.npub) { sendSocketMessage(socket, `${tag} NO Not authenticated\r\n`); return; } const mailboxName = Array.isArray(mailbox) ? mailbox[mailbox.length - 1] : mailbox; if (!mailboxName.startsWith("npub")) { sendSocketMessage( socket, `${tag} NO [READ-WRITE] SELECT failed, invalid mailbox\r\n`, ); return; } const mailboxNpub = nip19.decode<"npub">(mailboxName as NPub); if (!mailboxNpub) { sendSocketMessage( socket, `${tag} NO [READ-WRITE] SELECT failed, invalid mailbox\r\n`, ); return; } session.selectedMailingList = mailboxNpub.data; const threadsInMailBox = await countingRelay.count( [ { kinds: [11, 1111], "#P": [session.selectedMailingList], }, ], {}, ); const recentThreads = await countingRelay.count( [ { kinds: [11, 1111], "#P": [session.selectedMailingList], since: Math.floor((Date.now() - 1000 * 60 * 60 * 24) / 1000), }, ], {}, ); sendSocketMessage( socket, "* FLAGS (\\Flagged \\Seen \\Answered)\r\n", ); sendSocketMessage(socket, `* ${threadsInMailBox} EXISTS\r\n`); sendSocketMessage(socket, `* ${recentThreads} RECENT\r\n`); sendSocketMessage(socket, `${tag} OK [READ-WRITE] SELECT completed\r\n`); } function handleCreate( session: ImapSession, socket: net.Socket, tag: string, mailbox: ImapCommandParameters, ) { if (!session.npub) { sendSocketMessage(socket, `${tag} NO Not authenticated\r\n`); return; } const mailboxName = Array.isArray(mailbox) ? mailbox[mailbox.length - 1] : mailbox; if (!mailboxName.startsWith("npub")) { sendSocketMessage( socket, `${tag} NO [READ-WRITE] CREATE failed, invalid mailbox\r\n`, ); return; } const mailboxNpub = nip19.decode<"npub">(mailboxName as NPub); if (!mailboxNpub) { sendSocketMessage( socket, `${tag} NO [READ-WRITE] CREATE failed, invalid mailbox\r\n`, ); return; } const mailingList = mailingLists.find( (list: string) => list === mailboxNpub.data, ); if (mailingList) { sendSocketMessage( socket, `${tag} NO [READ-WRITE] CREATE failed, mailbox already exists\r\n`, ); return; } mailingLists.push(mailboxName); Deno.writeFileSync( "maling-lists.json", new TextEncoder().encode(JSON.stringify(mailingLists)), ); sendSocketMessage(socket, `${tag} OK [READ-WRITE] CREATE completed\r\n`); } function handleLogout(socket: net.Socket, tag: string) { sendSocketMessage(socket, "* BYE IMAP server logging out\r\n"); sendSocketMessage(socket, `${tag} OK LOGOUT completed\r\n`); socket.end(); } function handleList( session: ImapSession, socket: net.Socket, tag: string, _: ImapCommandParameters, filter: ImapCommandParameters, ) { if (!session.npub) { sendSocketMessage(socket, `${tag} NO Not authenticated\r\n`); return; } let filterMailingList = Array.isArray(filter) ? filter[filter.length - 1] : filter; if (filterMailingList.includes('"')) { filterMailingList = filterMailingList.split('"')[1]; } if (filterMailingList.startsWith("npub")) { const mailboxNpub = nip19.decode<"npub">(filterMailingList as NPub); if (!mailboxNpub) { sendSocketMessage( socket, `${tag} NO [READ-WRITE] LIST failed, invalid mailbox\r\n`, ); return; } const mailingList = mailingLists.find( (list: string) => list === filterMailingList, ); if (!mailingList) { sendSocketMessage( socket, `${tag} NO [READ-WRITE] LIST failed, mailbox does not exist\r\n`, ); return; } sendSocketMessage( socket, `* LIST (\\HasNoChildren) "" ${filterMailingList}\r\n`, ); sendSocketMessage(socket, `${tag} OK LIST completed\r\n`); } else if (filterMailingList === "*") { for (const mailingList of mailingLists) { sendSocketMessage( socket, `* LIST (\\HasNoChildren) "" ${mailingList}\r\n`, ); } sendSocketMessage(socket, `${tag} OK LIST completed\r\n`); } } function handleLsub( session: ImapSession, socket: net.Socket, tag: string, reference: ImapCommandParameters, mailbox: ImapCommandParameters, ) { if (!session.npub) { sendSocketMessage(socket, `${tag} NO Not authenticated\r\n`); return; } const referenceName = Array.isArray(reference) ? reference[reference.length - 1] : reference; const mailboxName = Array.isArray(mailbox) ? mailbox[mailbox.length - 1] : mailbox; if (referenceName !== "") { sendSocketMessage(socket, `${tag} NO LSUB failed, invalid reference\r\n`); return; } if (!mailboxName.endsWith("*")) { sendSocketMessage(socket, `${tag} NO LSUB failed, invalid mailbox\r\n`); return; } for (const mailingList of mailingLists) { sendSocketMessage(socket, `* LSUB () "" ${mailingList}\r\n`); } sendSocketMessage(socket, `${tag} OK LSUB completed\r\n`); } function handleSubscribe( session: ImapSession, socket: net.Socket, tag: string, mailbox: ImapCommandParameters, ) { if (!session.npub) { sendSocketMessage(socket, `${tag} NO Not authenticated\r\n`); return; } const mailboxName = Array.isArray(mailbox) ? mailbox[mailbox.length - 1] : mailbox; if (!mailboxName.startsWith("npub")) { sendSocketMessage( socket, `${tag} NO SUBSCRIBE failed, invalid mailbox\r\n`, ); return; } const mailingList = mailingLists.find( (list: string) => list === mailboxName, ); if (!mailingList) { sendSocketMessage( socket, `${tag} NO SUBSCRIBE failed, mailbox does not exist\r\n`, ); return; } sendSocketMessage(socket, `${tag} OK SUBSCRIBE completed\r\n`); } export function fetchAllEventsMatchingQuery(query: Filter[]): Promise { return new Promise((resolve) => { const allEvents: Event[] = []; const allThreadsSubscription = queryingRelay.subscribe( query, { onevent: (event) => { allEvents.push(event); }, oneose: () => { allThreadsSubscription.close(); resolve(allEvents); }, }, ); }); } async function handleUidStore( session: ImapSession, socket: net.Socket, tag: string, uid: ImapCommandParameters, storeItem: ImapCommandParameters, value: ImapCommandParameters, ) { if (!session.npub) { sendSocketMessage(socket, `${tag} NO Not authenticated\r\n`); return; } if (typeof uid !== "string") { sendSocketMessage(socket, `${tag} BAD UID STORE failed, invalid uid\r\n`); return; } if ( typeof storeItem !== "string" || storeItem.toLowerCase() !== "+flags" && storeItem.toLowerCase() !== "-flags" ) { sendSocketMessage( socket, `${tag} BAD UID STORE failed, invalid store item\r\n`, ); return; } if (!Array.isArray(value)) { sendSocketMessage(socket, `${tag} BAD UID STORE failed, invalid value\r\n`); return; } const eventId = findEventIdForUid(Number.parseInt(uid)); for (const flag of value) { if (flag.toLowerCase() === "\\seen") { await markMessageAsRead( session, eventId, storeItem.toLowerCase() === "+flags", ); } if (flag.toLowerCase() === "\\flagged") { await markMessageAsFlagged( session, eventId, storeItem.toLowerCase() === "+flags", ); } } const flags = await getFlags(session, eventId); sendSocketMessage( socket, `* 1 FETCH (FLAGS (${flags.join(" ")}) UID ${uid})\r\n`, ); sendSocketMessage(socket, `${tag} OK UID STORE completed\r\n`); } export default function startImapServer(): void { const server = net.createServer((socket: net.Socket) => { const session: ImapSession = { npub: null, selectedMailingList: null, }; try { sendSocketMessage(socket, "* OK Nostr over IMAP ready\r\n"); } catch (e) { console.error("Error sending initial greeting:", e); socket.end(); return; } socket.on("data", async (data: Buffer) => { if (socket.destroyed || socket.closed) { console.error("Socket is destroyed or closed"); return; } logMessage("in", data.toString()); const command = parseImapCommand(data.toString().trim()); console.log(command.command); if (!command.command) { sendSocketMessage( socket, `${command.tag} BAD Command not recognized\r\n`, ); return; } switch (command.command.toUpperCase()) { case "CAPABILITY": sendSocketMessage( socket, "* CAPABILITY IMAP4rev1 THREAD=REFERENCES\r\n", ); sendSocketMessage( socket, `${command.tag} OK CAPABILITY completed\r\n`, ); break; case "LOGIN": handleLogin( session, socket, command.tag, command.parameters[0], command.parameters[1], ); break; case "CREATE": handleCreate(session, socket, command.tag, command.parameters[0]); break; case "LIST": handleList( session, socket, command.tag, command.parameters[0], command.parameters[1], ); break; case "LSUB": handleLsub( session, socket, command.tag, command.parameters[0], command.parameters[1], ); break; case "SUBSCRIBE": handleSubscribe(session, socket, command.tag, command.parameters[0]); break; case "SELECT": await handleSelect( session, socket, command.tag, command.parameters[0], ); break; case "LOGOUT": handleLogout(socket, command.tag); break; case "UID FETCH": await handleUidFetch( session, socket, command.tag, command.parameters[0], command.parameters[1], ); break; case "UID STORE": await handleUidStore( session, socket, command.tag, command.parameters[0], command.parameters[1], command.parameters[2], ); break; default: console.log("Invalid command", command.command); sendSocketMessage( socket, `${command.tag} BAD Command not recognized\r\n`, ); } }); socket.on("error", (err: Error) => { console.error("Socket error:", err); if (socket.destroyed || socket.closed) { return; } socket.end(); }); }); const PORT = 1143; server.listen(PORT, () => { console.log(`IMAP server listening on port ${PORT}`); }); }