nostr-mailing-list/imapServer.ts

524 lines
12 KiB
TypeScript

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<Event[]> {
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}`);
});
}