DEV Community 👩‍💻👨‍💻

Cover image for How to test sent and received emails with Cypress 10, Ethereal and Nodemailer
Juan Pablo
Juan Pablo

Posted on • Updated on

How to test sent and received emails with Cypress 10, Ethereal and Nodemailer

Hi there! This is what I expect to be the first of many posts related to solutions, tricks, hacks and weird or not so common implementations of tools and techniques for Cypress.

Last week I was assigned the task of finding out if Cypress was well suited to test email flows. Keep in mind that for different reasons the Cypress framework I built is not integrated into the app code, and even the application under test can't be deployed locally, so the Cypress tests run always against the live app deployed on 3 environments (dev, stage and some tests against prod)

Since I am a Cypress fan my first answer to those assignments/questions is always "of course! Cypress can do anything!"... I must admit that I always regret having said that, not because I found it impossible to do, but because usually means I have to spend quite some time researching and building POC's.. but what the heck, that's part of the fun! :D

Anyway... for this particular assignment I found quickly a couple of posts. The first one was "Testing HTML emails using Cypress" by Gleb Bahmutov. But the problem I found was that it makes use of a plugin called smtp-tester, which runs a local smtp server, and to make use of that you need to manipulate your app's configuration so that, when executed locally, you can send the emails using the local smtp configuration. For me that was a NO-GO.

So I kept digging, also asked some questions on the Cypress's gitter, and Gleb directed me on the direction of Ethereal.email. I even found this post by him too "Full Testing of HTML Emails using SendGrid and Ethereal Accounts" I had never heard about it, but it's a really cool tool. It's basically an online service that creates a fake smtp server for you to use, where emails are actually never sent anywhere, and are deleted after a couple of hours.

Then I needed the part related to actually fetching the emails from the ethereal account, and I found this post which shows how to do it, using the imap-simple node package. My problem was that the particular package seems outdated and not maintained any more, and I needed something more "reliable" (the company does regular security scans on everything used). So I kept searching and found imapflow, which basically does the same thing, but with some little differences.

Here are the two basic scenarios anyone would probably need to test for emails:

  1. Signup process email verification - you want to test if the email that the signup process sends to the email account of the new user is correct (right format, right content, probably check if the verification code sent actually works, etc)

  2. Email notifications: whatever processes in the app that trigger a notification email which gets sent to an internal email.

This example will use the cypress dashboard signup page for testing the flow. To understand exactly how ethereal emails work and how they integrate with this email testing pattern I recommend you to visit the links I shared above (there are some nice diagrams there too)

Too much talk (or type)... let's get to it

Requirements:

Cypress 10.3^
Plugins:
recurse
nodemailer
imapflow
mailparser

Note that a good part of the code I'll share was extracted from Gleb Bahmutov's solution for testing html emails, and also extracted some ideas from this post. All I did was put the pieces together

These are the basic steps: (The code is properly commented to understand exactly what happens at each step)

  1. Create the email-account.js plugin file (I keep creating specific plugin files inside a plugins folder - on index.js plugin file of course). Here you will have:
  • The method that creates the ethereal account upon request via nodemailer
  • The method that gets the last email received in the inbox via imap (using imapflow), and parses it's content to then validate the different parts (using mailparser)
  • The method that sends an email with whatever content and an attachment

cypress/plugins/email-account.js

// use Nodemailer to get an Ethereal email inbox
// https://nodemailer.com/about/
const nodemailer = require('nodemailer')
// used to fetch emails from the inbox via imap protocol
const { ImapFlow } = require('imapflow');
// used to parse emails from the inbox
const simpleParser = require('mailparser').simpleParser

const makeEmailAccount = async () => {
    // Generate a new Ethereal email inbox account
    const testAccount = await nodemailer.createTestAccount()

    console.log('created new email account %s', testAccount.user)
    console.log('for debugging, the password is %s', testAccount.pass)

    const userEmail = {
        user: {
            email: testAccount.user,
            pass: testAccount.pass
        },

        /**
         * Utility method for getting the last email
         * for the Ethereal email account using ImapFlow.
         */
        async getLastEmail() {
            // Create imap client to connect later to the ethereal inbox and retrieve emails using ImapFlow
            let client = new ImapFlow({
                host: 'ethereal.email',
                port: 993,
                secure: true,
                auth: {
                    user: testAccount.user,
                    pass: testAccount.pass
                }
            });
            // Wait until client connects and authorizes
            await client.connect();

            let message;

            // Select and lock a mailbox. Throws if mailbox does not exist
            let lock = await client.getMailboxLock('INBOX');
            try {
                // fetch latest message source
                // client.mailbox includes information about currently selected mailbox
                // "exists" value is also the largest sequence number available in the mailbox
                message = await client.fetchOne(client.mailbox.exists, { source: true });
                console.log("The message: %s", message.source.toString());

                // list subjects for all messages
                // uid value is always included in FETCH response, envelope strings are in unicode.
                for await (let message of client.fetch('1:*', { envelope: true })) {
                    console.log(`${message.uid}: ${message.envelope.subject}`);
                }
            } finally {
                // Make sure lock is released, otherwise next `getMailboxLock()` never returns
                lock.release();
            }

            // log out and close connection
            await client.logout();

            const mail = await simpleParser(
                message.source
            )
            console.log(mail.subject)
            console.log(mail.text)

            // and returns the main fields + attachments array
            return {
                subject: mail.subject,
                text: mail.text,
                html: mail.html,
                attachments: mail.attachments
            }
        },

        /**
         * Utility method for sending an email
         * to the Ethereal email account created above.
         */
        async sendEmail() {
            // Generate test SMTP service account from ethereal.email
            // Only needed if you don't have a real mail account for testing

            // create reusable transporter object using the default SMTP transport
            let transporter = nodemailer.createTransport({
                host: "smtp.ethereal.email",
                port: 587,
                secure: false, // true for 465, false for other ports
                auth: {
                    user: testAccount.user, // generated ethereal user
                    pass: testAccount.pass, // generated ethereal password
                },
            });

            // send mail with defined transport object
            let info = await transporter.sendMail({
                from: '"Fred Foo 👻" <foo@example.com>', // sender address
                to: "bar@example.com, baz@example.com", // list of receivers
                subject: "Hello ✔", // Subject line
                text: "Hello world?", // plain text body
                html: "<b>Hello world?</b>", // html body
                attachments: [
                    {
                        filename: 'hello.json',
                        content: JSON.stringify({
                            name: "Hello World!"
                        })
                    }
                ]
            });
            console.log("Message sent: %s", info.messageId);
            // Preview only available when sending through an Ethereal account
            console.log("Preview URL: %s", nodemailer.getTestMessageUrl(info));
            return info.messageId
        }
    }
    return userEmail
}

module.exports = makeEmailAccount
Enter fullscreen mode Exit fullscreen mode
  1. Config file: we load the email-account plugin file, and create the tasks in the setupNodeEvents function that will call the plugin's methods

cypress.config.js

const { defineConfig } = require("cypress");
const makeEmailAccount = require('./cypress/plugins/email-account')

module.exports = defineConfig({

  e2e: {
    baseUrl: "https://dashboard.cypress.io",
    setupNodeEvents: async (on, config) => {
      const emailAccount = await makeEmailAccount()

      on('task', {
        getUserEmail() {
          return emailAccount.user
        },

        getLastEmail() {
          return emailAccount.getLastEmail()
        },

        sendEmail() {
          return emailAccount.sendEmail()
        }
      })
    },
  },
});
Enter fullscreen mode Exit fullscreen mode
  1. Write your tests...

spec.cy.js

/// <reference types="cypress" />
const { recurse } = require('cypress-recurse')

describe('Email confirmation', () => {
    let userEmail
    let userName

    before(() => {
        // get and check the test email only once before the tests
        cy.task("getUserEmail").then((user) => {
            cy.log(user.email)
            cy.log(user.pass)
            expect(user.email).to.be.a("string")
            userEmail = user.email
            userName = user.email.replace("@ethereal.email", "")
        })
    })

    it('Fill in signup form and validate confirmation email is received', () => {
        cy.visit("/signup/email").pause()
        cy.get("#email").type(userEmail)
        cy.get("[type=password]").type("9876oiuy)(/&")
        cy.get("button[type=submit]").click()

        cy.log("**redirects to /confirm**")
        cy.location("pathname").should("equal", "/verify")

        // retry fetching the email
        recurse(
            () => cy.task('getLastEmail'), // Cypress commands to retry
            Cypress._.isObject, // keep retrying until the task returns an object
            {
                timeout: 60000, // retry up to 1 minute
                delay: 5000, // wait 5 seconds between attempts
            },
        )
            .its('html')
            .then((html) => {
                cy.document({ log: false }).invoke({ log: false }, 'write', html)
            })
        cy.log('**email has the user name**')
        cy.contains('h1', "Activate your account").should('be.visible')
    })

    it("Send an email with attachment and validate", () => {
        cy.task("sendEmail").then((response) => {
            cy.log("The message id: " + response)
        })

        recurse(
            () => cy.task('getLastEmail'), // Cypress commands to retry
            Cypress._.isObject, // keep retrying until the task returns an object
            {
                timeout: 60000, // retry up to 1 minute
                delay: 5000, // wait 5 seconds between attempts
            },
        )
            .its('attachments')
            .then((attachments) => {
                expect(attachments[0].filename).to.eq("hello.json")
            })
    })
})
Enter fullscreen mode Exit fullscreen mode

And that's it. Of course, the part related to "sending emails" from you app, you would need to do some work to create the ethereal account and have your app send the notification emails (the second scenario I mentioned at the beginning) to the ethereal account. Then fetch and validate

Hope this is clear enough and helpful...

Cheers! and happy testing!

JP

Top comments (0)

DEV has this feature:

Settings

Go to your customization settings to nudge your home feed to show content more relevant to your developer experience level. 🛠