nostr-mailing-list/smtpServer.ts

374 lines
9.4 KiB
TypeScript

import * as net from "node:net";
import { finalizeEvent, getPublicKey } from "@nostr/tools";
import { nip19 } from "@nostr/tools";
import type { Note, NSec } from "@nostr/tools/nip19";
import type { Event, EventTemplate } from "@nostr/tools/core";
import { Relay } from "@nostr/tools/relay";
import { logMessage, sendSocketMessage } from "./utils.ts";
import { mailingLists, queryingRelay } from "./main.ts";
const publishingRelay = new Relay("wss://relay.damus.io");
await publishingRelay.connect();
interface SmtpSession {
authenticated: boolean;
privateKey: Uint8Array | null;
from: string | null;
recipients: string[];
data: string[];
dataMode: boolean;
}
function parseSmtpCommand(
line: string,
): { command: string; parameters: string[] } {
const parts = line.split(" ");
const command = parts[0].toUpperCase();
const parameters = parts.slice(1);
return { command, parameters };
}
function handleEhlo(
socket: net.Socket,
parameters: string[],
): void {
const domain = parameters[0] || "localhost";
sendSocketMessage(socket, `250-Hello ${domain}\r\n`);
sendSocketMessage(socket, "250-AUTH PLAIN\r\n");
sendSocketMessage(socket, "250 8BITMIME\r\n");
}
function handleAuth(
session: SmtpSession,
socket: net.Socket,
parameters: string[],
): void {
if (parameters[0] !== "PLAIN") {
sendSocketMessage(socket, "504 Unrecognized authentication type\r\n");
return;
}
const credentials = parameters[1];
if (!credentials) {
sendSocketMessage(socket, "334 \r\n");
return;
}
try {
const decoded = atob(credentials);
const parts = decoded.split("\0");
if (parts.length !== 3) {
sendSocketMessage(socket, "535 Authentication failed\r\n");
return;
}
const npubString = parts[1];
const nsecString = parts[2];
if (!nsecString.startsWith("nsec1")) {
sendSocketMessage(socket, "535 Authentication failed\r\n");
return;
}
const sk = nip19.decode<"nsec">(nsecString as NSec);
if (!sk?.data) {
sendSocketMessage(socket, "535 Authentication failed\r\n");
return;
}
const pkFromSk = getPublicKey(sk.data);
const npub = nip19.npubEncode(pkFromSk);
if (npubString === npub) {
session.authenticated = true;
session.privateKey = sk.data;
sendSocketMessage(socket, "235 Authentication successful\r\n");
} else {
sendSocketMessage(socket, "535 Authentication failed\r\n");
}
} catch (error) {
console.error("Auth error:", error);
sendSocketMessage(socket, "535 Authentication failed\r\n");
}
}
function handleMailFrom(
session: SmtpSession,
socket: net.Socket,
parameters: string[],
): void {
if (!session.authenticated) {
sendSocketMessage(socket, "530 Authentication required\r\n");
return;
}
const fromMatch = parameters[0].match(/<(.+)>/);
if (!fromMatch) {
sendSocketMessage(socket, "501 Syntax error in parameters\r\n");
return;
}
session.from = fromMatch[1];
session.recipients = [];
session.data = [];
session.dataMode = false;
sendSocketMessage(socket, "250 OK\r\n");
}
function handleRcptTo(
session: SmtpSession,
socket: net.Socket,
parameters: string[],
): void {
if (!session.authenticated) {
sendSocketMessage(socket, "530 Authentication required\r\n");
return;
}
if (!session.from) {
sendSocketMessage(socket, "503 MAIL command required first\r\n");
return;
}
const toMatch = parameters[0].match(/<(.+)>/);
if (!toMatch) {
sendSocketMessage(socket, "501 Syntax error in parameters\r\n");
return;
}
const recipient = toMatch[1];
session.recipients.push(recipient);
sendSocketMessage(socket, "250 OK\r\n");
}
function handleData(session: SmtpSession, socket: net.Socket): void {
if (!session.authenticated) {
sendSocketMessage(socket, "530 Authentication required\r\n");
return;
}
if (!session.from) {
sendSocketMessage(socket, "503 MAIL command required first\r\n");
return;
}
if (session.recipients.length === 0) {
sendSocketMessage(socket, "503 RCPT command required first\r\n");
return;
}
session.dataMode = true;
sendSocketMessage(socket, "354 End data with <CR><LF>.<CR><LF>\r\n");
}
async function publishNostrEvent(session: SmtpSession): Promise<boolean> {
if (
!session.privateKey || !session.from || session.recipients.length === 0 ||
session.data.length === 0
) {
return false;
}
try {
const content = session.data.join("\r\n");
let subject = "";
console.log("content", content);
const subjectMatch = content.match(/^Subject: (.+)$/m);
if (subjectMatch) {
subject = subjectMatch[1];
}
const messageIdMatch = content.match(/^Reply-To: (.+)$/m);
const tags = [];
if (messageIdMatch) {
const messageIdEncoded = messageIdMatch[1].split("@")[0].replace(
/-/g,
"",
);
const messageId = nip19.decode<"note">(messageIdEncoded as Note);
if (!messageId?.data) {
return false;
}
const nostrEvent = await new Promise<Event>((resolve) => {
queryingRelay.subscribe([{
ids: [messageId.data],
limit: 1,
}], {
onevent(evt) {
resolve(evt as Event);
},
});
});
tags.push([
...nostrEvent.tags.filter((tag) =>
tag[0] === "p" || tag[0] === "e" || tag[0] === "E"
),
]);
tags.push(["e", nostrEvent.id]);
}
const bodyIndex = content.indexOf("\r\n\r\n");
const messageBody = bodyIndex !== -1
? content.substring(bodyIndex + 4)
: content;
for (const recipientMail of session.recipients) {
const recipient = recipientMail.split("@")[0];
if (recipient.startsWith("npub")) {
try {
const decodedNpub = nip19.decode(recipient);
if (decodedNpub.type === "npub") {
if (mailingLists.includes(recipient)) {
tags.push(["P", decodedNpub.data]);
} else {
tags.push(["p", decodedNpub.data]);
}
}
} catch {
console.error("Invalid npub:", recipient);
}
}
}
if (subject) {
tags.push(["subject", subject]);
}
const unsignedEvent = {
kind: 1111,
created_at: Math.floor(Date.now() / 1000),
tags: tags,
content: messageBody,
pubkey: getPublicKey(session.privateKey),
};
const event = finalizeEvent(
unsignedEvent as EventTemplate,
session.privateKey,
);
await publishingRelay.publish(event);
return true;
} catch (error) {
console.error("Failed to publish Nostr event:", error);
return false;
}
}
function handleQuit(socket: net.Socket): void {
sendSocketMessage(socket, "221 Bye\r\n");
socket.end();
}
export default function startSmtpServer() {
const server = net.createServer((socket: net.Socket) => {
const session: SmtpSession = {
authenticated: false,
privateKey: null,
from: null,
recipients: [],
data: [],
dataMode: false,
};
sendSocketMessage(socket, "220 Nostr SMTP Service Ready\r\n");
socket.on("data", async (data) => {
if (socket.destroyed || socket.closed) {
return;
}
const message = data.toString();
logMessage("smtp-in", message);
if (session.dataMode) {
if (message.trim() === ".") {
session.dataMode = false;
const success = await publishNostrEvent(session);
if (success) {
sendSocketMessage(
socket,
"250 OK: Message accepted for delivery\r\n",
);
} else {
sendSocketMessage(socket, "554 Transaction failed\r\n");
}
session.data = [];
session.from = null;
session.recipients = [];
} else {
if (message.startsWith("..")) {
session.data.push(message.substring(1));
} else {
session.data.push(message);
}
}
return;
}
const lines = message.split("\r\n").filter((line) => line.trim() !== "");
for (const line of lines) {
const { command, parameters } = parseSmtpCommand(line);
switch (command) {
case "EHLO":
case "HELO":
handleEhlo(socket, parameters);
break;
case "AUTH":
handleAuth(session, socket, parameters);
break;
case "MAIL":
handleMailFrom(session, socket, parameters);
break;
case "RCPT":
handleRcptTo(session, socket, parameters);
break;
case "DATA":
handleData(session, socket);
break;
case "QUIT":
handleQuit(socket);
break;
case "RSET":
session.from = null;
session.recipients = [];
session.data = [];
session.dataMode = false;
sendSocketMessage(socket, "250 OK\r\n");
break;
case "NOOP":
sendSocketMessage(socket, "250 OK\r\n");
break;
default:
sendSocketMessage(socket, "502 Command not implemented\r\n");
}
}
});
socket.on("error", (err) => {
console.error("SMTP Socket error:", err);
if (!socket.destroyed && !socket.closed) {
socket.end();
}
});
});
const SMTP_PORT = 1025;
server.listen(SMTP_PORT, () => {
console.log(`SMTP server listening on port ${SMTP_PORT}`);
});
}