nostr-mailing-list/handleUidFetch.ts

337 lines
8.9 KiB
TypeScript

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<string, unknown>,
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<void> {
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`,
);
}
}