The Farrelly in text on fire

Nodemailer and Fastmail tutorial

9 minute read

I have a bunch of my older articles posted on Medium. It was where I first tried my hand at blogging about engineering and development. I mostly just wanted to get ideas out of my head, and experiences off my chest. To be fair, more or less, nothing has changed. The articles I post here have the same essence. However, Medium introduced a paywall for visitors who had already read one or more articles. Considering I was doing this writing for free and wasn't getting compensated1, it didn't quite sit right with me. So I created this website and started posting here instead.

However, I still get a weekly analytics email from Medium which details views and reads. It includes some pretty graphs and overall is very well formatted. It made me start to wonder... it would be nice to have essentially the same breakdown but for my website. How hard could it be?

So I began investigating how I could do it, turns out it's frightfully easy!

Background

Before we get started, a brief introduction to both NodeMailer and Fastmail for the uninitiated. NodeMailer is an open source NodeJS library which enables consumers to send emails programmatically. It is the defacto choice for handling emails for NodeJS. Fastmail is an Australian email host, which offers a plethora of handy features like email aliasing.

Setup

To get NodeMailer setup for you project, I'll refer you back to their docs. Unsurprisingly, you'll need a NodeJS project. Similarly for Fastmail, you'll need to create an account if you don't have one already.

These are the following things we're going to need:

  • A fastmail app password (not the password to your primary account).
  • Your primary fastmail email address (the one you use to sign into the service with).
  • A 'to' address (who the email is going to).
  • A 'from' email address & username. All of these should be put into an .env or similarly ignored config file.

Fastmail provide a help article detailing the steps to an app password. As of writing this article, web users you'll need to:

  1. Click the settings cog at the top right of your screen.
  2. Click 'Privacy & Security' under the 'Account' heading on the left hand panel.
  3. Click 'Manage app passwords and access'.
  4. Click the '+ New app password' button, and enter your password when prompted.
  5. Provide a name to distinguish the password, I've used the project name for mine. Then choose the level of access, following least privilege possible you'll probably just want to provide 'SMTP'. Then click 'Generate password'.
  6. Copy your app password to the clipboard.

Sending the email

Now this is the easy part, the least amount of code you'll need will be the following

import nodemailer, {Transporter} from "nodemailer";

const transporter = nodemailer.createTransport({
    host: "smtp.fastmail.com",
    port: 465,
    secure: true,
    auth: {
        user: process.env.MASTER_MAIL_USER,
        pass: process.env.MAIL_PASS,
    }
})

export const sendMail = async ({html, subject}: { html: string, subject: string }): Promise<void> => {
    try {
        const canSend = transporter.verify();
        if (!canSend) throw new Error("Could not send email, verify check failed.")
        const info = await transporter.sendMail({
            from: `"${process.env.FROM_MAIL_USER_NAME}" <${process.env.FROM_MAIL_USER}>`,
            to: process.env.TO_MAIL_USER,
            subject,
            html
        });
        console.log("Message sent:", info.messageId);
    } catch (e) {
        console.error('Send Mail Error:', e)
    }

}

also available as a gist.

In my use case, I'm the only recipient and the only sender. So the from/to values are hardcoded via the project's .env. Depending on your use case, you may want to add those as props to the function instead.

The .env values are mapped as follows, I've put in some test values for clarity.

// your primary fastmail email address
MASTER_MAIL_USER=ReallyCoolDev@DevTown.com
// your fastmail app password
MAIL_PASS=2489fwdj793w

// you can put whatever you want here
FROM_MAIL_USER_NAME=Analytics
FROM_MAIL_USER=analytics@DevTown.com

// who the email is being sent to
TO_MAIL_USER=AnotherCoolDev@DevTown.com

And that's it, you can send emails!

Extra for nerds
Test sending emails

NodeMailer provides a free-to-use email service that you can test sending emails to, called ethereal.email2. You can create an account either via visiting the create account page, in which a name, email, and password are randomly generated for you. Or you can create one programmatically just by calling createTestAccount(), e.g.

import nodemailer, {Transporter} from "nodemailer";

export const getDevTransporter = async (): Promise<Transporter> => {
    const account = await nodemailer.createTestAccount();

    return nodemailer.createTransport({
        host: account.smtp.host,
        port: account.smtp.port,
        secure: account.smtp.secure,
        auth: {
            user: account.user,
            pass: account.pass,
        },
    });
}

The account's name, email address, and password will be logged to your cli. You can grab those details then log into ethereal.email to see the email you've sent.

One small problem with the above is that it'll generate a new account on every invocation. That means you'll need to log out and back into ethereal.email every time you send an email. To get around that you can reuse the same account details. Either by passing the hardcoded data in, or allowing a function like the below to manage it for you

import nodemailer, {Transporter} from "nodemailer";
import path from "path";
import fs from "node:fs";

const CACHE_FILE = path.resolve(process.cwd(), '.ethereal.json');

type EtherealTestAccount = Omit<nodemailer.TestAccount, 'web' | 'imap' | 'pop3'>;

export const getDevTransporter = async (): Promise<Transporter> => {
    const account = await getAccount();

    return nodemailer.createTransport({
        host: account.smtp.host,
        port: account.smtp.port,
        secure: account.smtp.secure,
        auth: {
            user: account.user,
            pass: account.pass,
        },
    });
}

/**
	* Fetch (or create) an Ethereal test account.
	* Returns a plain object: { user, pass, smtp: { host, port, secure } }
*/
const getAccount = async (): Promise<EtherealTestAccount> => {
    const cached = readCache();
    if (cached) return cached;

    const testAccount = await nodemailer.createTestAccount();
    const account: EtherealTestAccount = {
        user: testAccount.user,
        pass: testAccount.pass,
        smtp: {
            host: testAccount.smtp.host,
            port: testAccount.smtp.port,
            secure: testAccount.smtp.secure,
        },
    };

    writeCache(account);
    console.log(`Account created and cached: ${account.user}:${account.pass}`);

    return account;
}

const readCache = (): EtherealTestAccount | null => {
    try {
        const raw = fs.readFileSync(CACHE_FILE, 'utf8');
        const data = JSON.parse(raw);
        // Minimal sanity-check so a half-written file is ignored.
        if (data && data.user && data.pass && data.smtp) return data;
        throw new Error('Invalid cache file');
    } catch (e: unknown) {
        console.error('Read cache error: ', e)
    }

    return null;
}

const writeCache = (account: EtherealTestAccount) => fs.writeFileSync(CACHE_FILE, JSON.stringify(account, null, 2), 'utf8');

What this file does is:

  1. Exports the devTransporter function, which you could conditionally call contingent on your current environment (i.e environment === 'development').
  2. Reads the already created and cached .ethereal.json file, which has a previously used name, email address, and password. Or,
  3. Creates a new test account and saves the details to the ethereal.json file. Allowing you to hit the cache in future instead.

You'll want to add ethereal.json to your .gitignore, otherwise you'll be updating it incessantly.

Email formatting

As you would've noticed in the above snippet and gist, HTML is provided in the email which is sent. You could instead send plaintext, however, as you can imagine that's not very pretty. So your options are to either use an email formatting library, in which there are many. Such as MJML, Handlebars, react-email and many, many more. Or, you can handwrite some HTML yourself.

If you're planning on creating numerous templates along with making tweaks and changes. I'd pick a library. However, for my low tech use case handwriting HTML is fine. I'm only sending one template, to one user (myself), once a week.

If you're going low-tech and handwriting HTML, you'll be pleased to learn that everything is tables again. Just like HTML was in the 2000s. Email clients have differing levels of support for CSS, so going for old school tried and true seems to be the best path forward. Here's a snippet of the template that sends me analytics every week now, bask in it's glory

<table width="500" class="top-stats">
	<tr>
		<td width="150" height="50" align="center">
			<h4>${highlights.totalViews}</h4>
		</td>
		<td width="150" height="50" align="center">
			<h4>${formatRoute(highlights.topEntryPoint.route)} ${highlights.topEntryPoint.percentage}%</h4>
		</td>
	</tr>
	<tr>
		<td width="150" height="50" align="center">
			<p class="top-stats__subtitle">total views</p>
		</td>
		<td width="150" height="50" align="center">
			<p class="top-stats__subtitle">top entry point</p>
		</td>
	</tr>
</table>

Even my IDE is disgusted, every property has a strikethrough with the message "obsolete attribute".


Footnotes

  1. I don't want to be compensated for the articles I post. The reason I post them is to give back to the programming community, along with documenting my experiences. However, providing a paywall that affects not just visitors but writers like myself too is a bizarre move. I write content on the platform for free, but I can't see that same content for free?

  2. How can you not love the open source community when things like this are provided free of charge?

TheFarrelly 2026

Made with ❤️

We all deserve privacy