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}`);
|
|
});
|
|
}
|