524 lines
		
	
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			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}`);
 | 
						|
  });
 | 
						|
}
 |