374 lines
9.4 KiB
TypeScript
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,
|
|
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}`);
|
|
});
|
|
}
|