DEV Community

ymerkos
ymerkos

Posted on

SMTP Server and Client from native node.js

B"H

I've seen many blogs and stack overflow questions about setting up nodejs to use a pre-existing smtp server, especially through modules like nodemailer etc. Some of what I've already seen:

https://www.zeolearn.com/magazine/sending-and-receiving-emails-using-nodejs

https://stackoverflow.com/questions/36796891/use-smtp-client-to-send-email-without-providing-password

https://stackoverflow.com/questions/40004022/how-can-i-create-a-custom-smtp-server-to-send-out-notification-emails-in-nodejs

https://stackoverflow.com/questions/4113701/sending-emails-in-node-js (DON'T KNOW WHY IT IS CLOSED)

https://stackoverflow.com/questions/36796891/use-smtp-client-to-send-email-without-providing-password/36796943#36796943

https://stackoverflow.com/questions/19877246/nodemailer-with-gmail-and-nodejs

https://stackoverflow.com/questions/23016208/nodemailer-send-email-without-smtp-transport -- this is a tiny bit closer to what I want

https://stackoverflow.com/questions/40004022/how-can-i-create-a-custom-smtp-server-to-send-out-notification-emails-in-nodejs/40004819 -- this one is so close yet no answers

https://stackoverflow.com/questions/26196467/sending-email-via-node-js-using-nodemailer-is-not-working

https://stackoverflow.com/questions/46936506/smtpjs-api-not-working-is-there-any-way-to-send-emails-using-smtp-server-with-j

https://stackoverflow.com/questions/47793869/email-nodejs-cannot-send

https://stackoverflow.com/questions/5476326/any-suggestion-for-smtp-mail-server-in-nodejs -- this one may be the only one that even attempts to answer it, although from the docs for the service mentioned there (smtp-server), I don't see where the actual makings of the SMTP server from scratch are, i.e. I don't see the part that shows how to make your own myemail@mydomain.com using nodeJS (assuming the NodeJS server is configured on some kind of linux VM like google compete engine).

All of these answers and blogs only addressed sending emails via some other email client.

I am not interested in any other email servers.

I don't believe in gmail -- or any other 3rd party email providers.

I want to host my own.

From my own server.

Don't question my intentions.

It's a perfectly valid programming question:

How can I build an SMTP mail server entirely from scratch, utilizing only the "net" built-in library in Node.js, and not relying on any external dependencies? Assuming I have already registered my own domain and have it hosted on a virtual machine with HTTPS, I aim for this server to have the capability to both send and receive emails using the address myemail@mydomain.com, without involving any third-party servers.

What are the initial steps to embark on this project? Are there any references or tutorials available that specifically deal with the SMTP socket protocols? These resources would provide a valuable starting point for this endeavor.

I have already attempted to develop an SMTP client. While its current objective is merely to send a single email to any email provider, I have encountered an issue where, despite not receiving any error messages, the emails fail to appear, even in spam folders. Interestingly, the server file does successfully receive emails. The concern here primarily lies with the client file.

For my DKIM key I use this basic script to generate it

/**
 * B"H
 * Generate DKIM key pairs for email usage
 */

const { generateKeyPairSync } = require('crypto');

const { publicKey, privateKey } = generateKeyPairSync('rsa', {
  modulusLength: 2048,
});

console.log('Private Key:', privateKey.export({
  type: 'pkcs1',
  format: 'pem',
}));
console.log('Public Key:', publicKey.export({
  type: 'pkcs1',
  format: 'pem',
}));
Enter fullscreen mode Exit fullscreen mode

and add the correct record

v=DKIM1; k=rsa; p=PUBLIC_KEY_without_---begin rsa or --end--rsa liens or new lines
Enter fullscreen mode Exit fullscreen mode

Server (working at least at a basic level):

/**
 * B"H
 * @module AwtsMail
 */

const AwtsmoosClient = require("./awtsmoosEmailClient.js");
const net = require('net');
const CRLF = '\r\n';

module.exports = class AwtsMail {
    constructor() {
        console.log("Starting instance of email");

        this.server = net.createServer(socket => {
            console.log("Some connection happened!", Date.now());
            socket.write('220 awtsmoos.one ESMTP Postfix' + CRLF);

            let sender = '';
            let recipients = [];
            let data = '';
            let receivingData = false;
            let buffer = '';

            socket.on('data', chunk => {
                buffer += chunk.toString();
                let index;
                while ((index = buffer.indexOf(CRLF)) !== -1) {
                    const command = buffer.substring(0, index);
                    buffer = buffer.substring(index + CRLF.length);

                    console.log("Received command:", command);
                    console.log("Command length:", command.length);

                    if (receivingData) {
                        if (command === '.') {
                            receivingData = false;
                            console.log("Received email data:", data);

                            socket.write(`250 2.0.0 Ok: queued as 12345${CRLF}`);

                            // Simulate sending a reply back.
                            if (sender) {
                              console.log("The email has ended!")
                              /*
                                console.log(`Sending a reply back to ${sender}`);
                                const replyData = `Subject: Reply from Awtsmoos ${
                                  Math.floor(Math.random() * 8)
                                }\r\n\r\nB"H\n\nHello from the Awtsmoos, the time is ${
                                  Date.now()
                                }.`;
                                this.smtpClient.sendMail('reply@awtsmoos.one', sender, replyData);
                            */
                            }
                        } else {
                            data += command + CRLF;
                        }
                        continue;
                    }

                    if (command.startsWith('EHLO') || command.startsWith('HELO')) {
                        socket.write(`250-Hello${CRLF}`);
                        socket.write(`250 SMTPUTF8${CRLF}`);
                    } else if (command.startsWith('MAIL FROM')) {
                        sender = command.slice(10);
                        socket.write(`250 2.1.0 Ok${CRLF}`);
                        console.log("The SENDER is:", sender);
                    } else if (command.startsWith('RCPT TO')) {
                        recipients.push(command.slice(8));
                        socket.write(`250 2.1.5 Ok${CRLF}`);
                    } else if (command.startsWith('DATA')) {
                        receivingData = true;
                        socket.write(`354 End data with <CR><LF>.<CR><LF>${CRLF}`);
                    } else if (command.startsWith('QUIT')) {
                        socket.write(`221 2.0.0 Bye${CRLF}`);
                        socket.end();
                    } else {
                        console.log("Unknown command:", command);
                        socket.write('500 5.5.1 Error: unknown command' + CRLF);
                    }
                }
            });

            socket.on("error", err => {
                console.log("Socket error:", err);
            });

            socket.on("close", () => {
                console.log("Connection closed");
            });
        });

        //this.smtpClient = new AwtsmoosClient("awtsmoos.one");

        this.server.on("error", err => {
            console.log("Server error: ", err);
        });
    }

    shoymayuh() {
        this.server.listen(25, () => {
            console.log("Awtsmoos mail listening to you, port 25");
        }).on("error", err => {
            console.log("Error starting server:", err);
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

I have a domain (awtsmoos.one) that has the correct A record for the ip address, mx records, spf, dkim a dmarc records configured.

This server code does successfully receive email data.
The problem is with the client, no matter what it has not sent even one message to any email provider (even test providers / 10 minute mails etc.)

/**
 *B"H
 * @module AwtsmoosEmailClient
 * A client for sending emails.
 * @requires crypto
 * @requires net
 * @requires tls
 */

const crypto = require('crypto');
const net = require('net');

const CRLF = '\r\n';

class AwtsmoosEmailClient {
    constructor(smtpServer, port = 25, privateKey = null) {
        this.smtpServer = smtpServer;
        this.port = port;
        this.privateKey = privateKey ? privateKey.replace(/\\n/g, '\n') : null;
        this.multiLineResponse = '';
        this.previousCommand = '';
    }

    /**
     * Canonicalizes headers and body in relaxed mode.
     * @param {string} headers - The headers of the email.
     * @param {string} body - The body of the email.
     * @returns {Object} - The canonicalized headers and body.
     */
    canonicalizeRelaxed(headers, body) {
        const canonicalizedHeaders = headers.split(CRLF)
            .map(line => line.toLowerCase().split(/\s*:\s*/).join(':').trim())
            .join(CRLF);

        const canonicalizedBody = body.split(CRLF)
            .map(line => line.split(/\s+/).join(' ').trimEnd())
            .join(CRLF).trimEnd();

        return { canonicalizedHeaders, canonicalizedBody };
    }

    /**
     * Signs the email using DKIM.
     * @param {string} domain - The sender's domain.
     * @param {string} selector - The selector.
     * @param {string} privateKey - The private key.
     * @param {string} emailData - The email data.
     * @returns {string} - The DKIM signature.
     */
    signEmail(domain, selector, privateKey, emailData) {
        const [headers, ...bodyParts] = emailData.split(CRLF + CRLF);
        const body = bodyParts.join(CRLF + CRLF);

        const { canonicalizedHeaders, canonicalizedBody } = this.canonicalizeRelaxed(headers, body);
        const bodyHash = crypto.createHash('sha256').update(canonicalizedBody).digest('base64');

        const dkimHeader = `v=1;a=rsa-sha256;c=relaxed/relaxed;d=${domain};s=${selector};bh=${bodyHash};h=from:to:subject:date;`;
        const signature = crypto.createSign('SHA256').update(dkimHeader + canonicalizedHeaders).sign(privateKey, 'base64');

        return `${dkimHeader}b=${signature}`;
    }

    /**
     * Determines the next command to send to the server.
     * @returns {string} - The next command.
     */
    getNextCommand() {
        const commandOrder = ['EHLO', 'MAIL FROM', 'RCPT TO', 'DATA', 'END OF DATA'];
        const currentIndex = commandOrder.indexOf(this.previousCommand);

        if (currentIndex === -1) {
            throw new Error(`Unknown previous command: ${this.previousCommand}`);
        }

        if (currentIndex + 1 >= commandOrder.length) {
            throw new Error('No more commands to send.');
        }

        return commandOrder[currentIndex + 1];
    }

    /**
     * Handles the SMTP response from the server.
     * @param {string} line - The response line from the server.
     * @param {net.Socket} client - The socket connected to the server.
     * @param {string} sender - The sender email address.
     * @param {string} recipient - The recipient email address.
     * @param {string} emailData - The email data.
     */
    handleSMTPResponse(line, client, sender, recipient, emailData) {
        console.log('Server Response:', line);

        this.handleErrorCode(line);

        if (line.endsWith('-')) {
            console.log('Multi-line Response:', line);
            return;
        }

        this.previousCommand = this.currentCommand;
        const nextCommand = this.getNextCommand();

        const commandHandlers = {
            'EHLO': () => client.write(`MAIL FROM:<${sender}>${CRLF}`),
            'MAIL FROM': () => client.write(`RCPT TO:<${recipient}>${CRLF}`),
            'RCPT TO': () => client.write(`DATA${CRLF}`),
            'DATA': () => client.write(`${emailData}${CRLF}.${CRLF}`),
            'END OF DATA': () => client.end(),
        };

        const handler = commandHandlers[nextCommand];

        if (!handler) {
            throw new Error(`Unknown next command: ${nextCommand}`);
        }

        handler();
        this.currentCommand = nextCommand;
    }

    /**
     * Handles error codes in the server response.
     * @param {string} line - The response line from the server.
     */
    handleErrorCode(line) {
        if (line.startsWith('4') || line.startsWith('5')) {
            throw new Error(line);
        }
    }

    /**
     * Sends an email.
     * @param {string} sender - The sender email address.
     * @param {string} recipient - The recipient email address.
     * @param {string} subject - The subject of the email.
     * @param {string} body - The body of the email.
     * @returns {Promise} - A promise that resolves when the email is sent.
     */
    async sendMail(sender, recipient, subject, body) {
        return new Promise((resolve, reject) => {
            const client = net.createConnection(this.port, this.smtpServer);
            client.setEncoding('utf-8');
            let buffer = '';

            const emailData = `From: ${sender}${CRLF}To: ${recipient}${CRLF}Subject: ${subject}${CRLF}${CRLF}${body}`;
            const domain = 'awtsmoos.com';
            const selector = 'selector';
            const dkimSignature = this.signEmail(domain, selector, this.privateKey, emailData);
            const signedEmailData = `DKIM-Signature: ${dkimSignature}${CRLF}${emailData}`;

            client.on('connect', () => {
                this.currentCommand = 'EHLO';
                client.write(`EHLO ${this.smtpServer}${CRLF}`);
            });

            client.on('data', (data) => {
                buffer += data;
                let index;
                while ((index = buffer.indexOf(CRLF)) !== -1) {
                    const line = buffer.substring(0, index).trim();
                    buffer = buffer.substring(index + CRLF.length);

                    if (line.endsWith('-')) {
                        this.multiLineResponse += line + CRLF;
                        continue;
                    }

                    const fullLine = this.multiLineResponse + line;
                    this.multiLineResponse = '';

                    try {
                        this.handleSMTPResponse(fullLine, client, sender, recipient, signedEmailData);
                    } catch (err) {
                        client.end();
                        reject(err);
                        return;
                    }
                }
            });

            client.on('end', resolve);
            client.on('error', reject);
            client.on('close', () => {
                if (this.previousCommand !== 'END OF DATA') {
                    reject(new Error('Connection closed prematurely'));
                } else {
                    resolve();
                }
            });
        });
    }
}

const privateKey = process.env.BH_key;
const smtpClient = new AwtsmoosEmailClient('awtsmoos.one', 25, privateKey);

async function main() {
    try {
        await smtpClient.sendMail('me@awtsmoos.com', 'awtsmoos@gmail.com', 'B"H', 'This is a test email.');
        console.log('Email sent successfully');
    } catch (err) {
        console.error('Failed to send email:', err);
    }
}

main();

module.exports = AwtsmoosEmailClient;
Enter fullscreen mode Exit fullscreen mode

Top comments (0)