337 lines
8.9 KiB
TypeScript
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`,
|
|
);
|
|
}
|
|
}
|