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 .\r\n"); } async function publishNostrEvent(session: SmtpSession): Promise { 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((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}`); }); }