import type { Socket } from "node:net"; import type { ImapCommandParameters } from "./imapParsing.ts"; import { fetchAllEventsMatchingQuery, type ImapSession } from "./imapServer.ts"; import * as nip19 from "@nostr/tools/nip19"; import type { Event } from "@nostr/tools"; import { findUidForEvent, getByteLength, getFlags, parseBasicNostrMarkdown, parseUid, sendSocketMessage, } from "./utils.ts"; const eventCache: { mailingList: string; timestamp: number; events: Event[]; } = { mailingList: "", timestamp: 0, events: [], }; const CACHE_EXPIRY = 5 * 60 * 1000; function buildHeaders( event: Event, session: ImapSession, headersRequested: string[], ): string { if (!session.selectedMailingList) { throw new Error("No mailing list selected"); } let headersResponse = "\r\n"; const subjectTag = event.tags.find((tag) => tag[0].toLowerCase() === "subject" ); headersResponse += `From: ${nip19.npubEncode(event.pubkey)}@nostr\r\n`; headersResponse += `To: ${ nip19.npubEncode(session.selectedMailingList) }@nostr\r\n`; headersResponse += `Reply-To: ${nip19.noteEncode(event.id)}@nostr\r\n`; if (headersRequested.includes("SUBJECT")) { headersResponse += `Subject: ${ subjectTag ? subjectTag[1] : "No subject" }\r\n`; } if (headersRequested.includes("DATE")) { headersResponse += `Date: ${ new Date(event.created_at * 1000).toUTCString() }\r\n`; } const threadId = event.tags.find((tag) => tag[0] === "E"); const messageThread = []; if (threadId) { messageThread.push(threadId[1]); } headersResponse += `Message-ID: <${event.id}@nostr>\r\n`; const eTags = event.tags.filter((tag) => tag[0] === "e"); if (eTags.length > 0) { for (const eTag of eTags) { messageThread.push(eTag[1]); } } if (messageThread.length > 0) { headersResponse += `References: ${ messageThread.map((id) => `<${id}@nostr>`).join(" ") }\r\n`; headersResponse += `In-Reply-To: <${ messageThread[messageThread.length - 1] }@nostr>\r\n`; headersResponse += `Thread-Index: ${messageThread.join("")}@nostr\r\n`; headersResponse += `X-Thread-Depth: ${messageThread.length}\r\n`; } headersResponse += "Content-Type: text/html; charset=utf-8\r\n"; headersResponse += "\r\n"; return headersResponse; } function buildFullMessageBody(event: Event, session: ImapSession): string { if (!session.selectedMailingList) { throw new Error("No mailing list selected"); } const subjectTag = event.tags.find((tag) => tag[0].toLowerCase() === "subject" ); const subject = subjectTag ? subjectTag[1] : "No subject"; const threadTag = event.tags.find((tag) => tag[0] === "E"); const threadId = threadTag ? threadTag[1] : event.id; const contentParts = [ `From: ${nip19.npubEncode(event.pubkey)}@nostr`, `To: ${nip19.npubEncode(session.selectedMailingList)}@nostr`, `Reply-To: ${nip19.noteEncode(event.id)}@nostr`, `Subject: ${subject}`, `Date: ${new Date(event.created_at * 1000).toUTCString()}`, `Message-ID: <${event.id}@nostr>`, "Content-Type: text/html; charset=utf-8", ]; const messageThread = []; if (threadId !== event.id) { messageThread.push(threadId); } const eTags = event.tags.filter((tag) => tag[0] === "e"); if (eTags.length > 0) { for (const eTag of eTags) { messageThread.push(eTag[1]); } } if (messageThread.length > 0) { contentParts.push( `References: ${messageThread.map((id) => `<${id}@nostr>`).join(" ")}`, ); contentParts.push( `In-Reply-To: <${messageThread[messageThread.length - 1]}@nostr>`, ); contentParts.push( `Thread-Index: ${messageThread.join("")}@nostr`, ); contentParts.push( `X-Thread-Depth: ${messageThread.length}`, ); } contentParts.push( "", `${event.content}`, ); return contentParts.join("\r\n"); } async function handleDataItem( item: string | Record, event: Event, session: ImapSession, dataPieces: string[], ) { if (item === "UID") return; if (item === "FLAGS") { const flags = await getFlags(session, event.id); dataPieces.push(`FLAGS (${flags.join(" ")})`); return; } if (item === "RFC822.SIZE") { dataPieces.push(`RFC822.SIZE ${JSON.stringify(event).length}`); return; } if (typeof item === "object" && item["BODY.PEEK[HEADER.FIELDS]"]) { const headersRequested = (item["BODY.PEEK[HEADER.FIELDS]"] as string[]).map( (x: string) => x.toUpperCase(), ); const headersResponse = buildHeaders(event, session, headersRequested); dataPieces.push( `BODY[HEADER.FIELDS (${ (item["BODY.PEEK[HEADER.FIELDS]"] as string[]).join(" ") })] {${headersResponse.length}}${headersResponse}`, ); return; } if ( typeof item === "string" && item.startsWith("BODY") ) { const contentText = buildFullMessageBody(event, session); const contentHtml = parseBasicNostrMarkdown(contentText); const bodyMessage = `BODY[] {${ getByteLength(contentHtml) }}\r\n${contentHtml}`; dataPieces.push(bodyMessage); return; } if (item === "BODY.PEEK[HEADER]" || item === "BODY[HEADER]") { const allHeadersResponse = buildHeaders( event, session, [ "FROM", "TO", "SUBJECT", "DATE", "CONTENT-TYPE", "MESSAGE-ID", "THREAD-ID", ], ); dataPieces.push( `BODY[HEADER] {${allHeadersResponse.length}}${allHeadersResponse}`, ); return; } if (item === "BODY.PEEK[TEXT]" || item === "BODY[TEXT]") { dataPieces.push(`BODY[TEXT] {${event.content.length}}\r\n${event.content}`); return; } if (item === "ENVELOPE") { const subjectTag = event.tags.find((tag) => tag[0].toLowerCase() === "subject" ); const subject = subjectTag ? subjectTag[1] : "No subject"; const date = new Date(event.created_at * 1000).toUTCString(); let envelopeStr = `ENVELOPE ("${date}" "${subject}" (("" NIL "${ nip19.npubEncode(event.pubkey) }" "nostr")) (("" NIL "${ nip19.npubEncode(event.pubkey) }" "nostr")) (("" NIL "${nip19.npubEncode(event.pubkey)}" "nostr"))`; if (session.selectedMailingList) { envelopeStr += ` ((NIL NIL "${ nip19.npubEncode(session.selectedMailingList) }" "nostr"))`; } else { envelopeStr += ` ((NIL NIL "unknown" "nostr"))`; } envelopeStr += ` NIL NIL NIL "<${event.id}@nostr>")`; dataPieces.push(envelopeStr); return; } console.log("Unknown item", item); } export async function handleUidFetch( session: ImapSession, socket: Socket, tag: string, uid: ImapCommandParameters, data_items: ImapCommandParameters, ): Promise { try { if (!session.selectedMailingList) { sendSocketMessage(socket, `${tag} NO No mailbox selected\r\n`); return; } let allThreads: Event[]; const now = Date.now(); if ( eventCache.mailingList === session.selectedMailingList && now - eventCache.timestamp < CACHE_EXPIRY ) { allThreads = eventCache.events; } else { allThreads = await fetchAllEventsMatchingQuery([ { kinds: [11, 1111], "#P": [session.selectedMailingList], }, ]); eventCache.mailingList = session.selectedMailingList; eventCache.timestamp = now; eventCache.events = allThreads; } let start = 0; let end = 0; try { const range = parseUid(uid); start = range[0]; end = range[1]; } catch (error) { if (error instanceof Error) { sendSocketMessage( socket, `${tag} BAD Invalid UID range: ${error.message}\r\n`, ); } else { sendSocketMessage(socket, `${tag} BAD Invalid UID range\r\n`); } return; } let counter = 1; const filteredEvents = allThreads.filter((event) => { const eventUid = findUidForEvent(event); return eventUid >= start && eventUid <= end; }); filteredEvents.sort((a, b) => findUidForEvent(a) - findUidForEvent(b)); for (const event of filteredEvents) { const eventUid = findUidForEvent(event); let message = `* ${counter++} FETCH (`; const dataPieces: string[] = [`UID ${eventUid}`]; if (Array.isArray(data_items)) { for (const item of data_items) { await handleDataItem(item, event, session, dataPieces); } } else { await handleDataItem(data_items, event, session, dataPieces); } message += `${dataPieces.join(" ")})\r\n`; sendSocketMessage(socket, message); } sendSocketMessage(socket, `${tag} OK UID FETCH completed\r\n`); } catch (error) { console.error("Error in UID FETCH handler:", error); const errorMessage = error instanceof Error ? error.message : "Unknown error"; sendSocketMessage( socket, `${tag} NO Error processing FETCH: ${errorMessage}\r\n`, ); } }