Send With Confidence
Partner with the email service trusted by developers and marketers for time-savings, scalability, and delivery expertise.
Time to read: 7 minutes
We recently passed a huge milestone of over 3 trillion emails sent.In our testing environments, we send our emails to test email inboxes in self-hosted Squirrelmail servers to avoid sending out test emails to actual email inbox service providers such as Gmail. Many important flows require the user to check their email, click on an actionable link, redirect back to the web application, and then continue onward at some download or verification success page.
cy.task()
to run arbitrary code we define in a Node server to assist us with dealing with emails. Also, if the later code snippets with TypeScript are a bit confusing, it may clear things up to see our blog post about how we typed our Cypress tests. You can still modify the code in your own Cypress tests by removing the type definitions and sticking to JavaScript-only syntax.
cy.task()
plugins to:
squirrelmail.ts
that we would later import in our plugins/index.ts
for our cy.task()
function definitions.
cy.request()
, follow the redirect link back to the web app we control and own on the one superdomain, and proceed with verifying success states on the page we redirected to.
cy.visit(linkPath)
to redirect back to your app. In the case of Twilio SendGrid links, the links may look like “...sendgrid.net?...” if you have link tracking on for your emails or “brandedlink.com” if you have link branding on. That is why we would need to make an HTTP request and extract the redirect path to do a cy.visit(redirectPath)
because the immediate “href” of the links do not match up with our web app.
cy.task()
plugin functions we implemented to do more read and delete actions with matching emails in our inboxes. We created these functions to properly reset the user’s email inbox state before we trigger these email flows in the web pages, wait for the emails to arrive in the inbox, and finally follow the links back to their success states. We summarize the key steps for your Cypress tests below:
cy.task(“teardownMatchingEmails”)
.cy.task(“awaitEmailInSquirrelmailInbox”)
.cy.task(“squirrelmailSearchBySubject”)
.cy.request(“link”)
and follow the redirect response back to the web app or visit the path if the links already match up with your superdomain with cy.visit(“emailLinkToWebApp”)
.Partner with the email service trusted by developers and marketers for time-savings, scalability, and delivery expertise.
// in an emailjs-imap-client.d.ts so we can type the imap client functions ourselves | |
declare module 'emailjs-imap-client'; | |
// squirrelmail.ts (where we define all of our email related task functions) | |
import ImapClient, { LOG_LEVEL_NONE as none } from 'emailjs-imap-client'; | |
const Squirrelmail = () => { | |
const squirrelmailServer = "somesquirrelmailserver.net" | |
const port = 993; | |
// ...Other email related functions | |
// Teardown matching emails by search string aka subject line | |
// Needs credentials for user's email inbox | |
// This will return true if we successfully tear down all matching emails | |
// Otherwise, return false if it errors out and fails to do so | |
const teardownMatchingEmails = ( | |
user: string, | |
pass: string, | |
searchString: string | |
) => { | |
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // So we don't instafail because self-signed cert | |
const client = new ImapClient(squirrelMailServer, port, { | |
auth: { | |
user, | |
pass, | |
}, | |
logLevel: none, | |
}); | |
return client | |
.connect() | |
.then(() => | |
// Find all emails with matching subject line and return their uids | |
client.search( | |
'inbox', | |
{ | |
header: ['subject', searchString], | |
}, | |
{ byUid: true } | |
) | |
) | |
.then((results: number[]) => | |
results.map((uid: number) => { | |
console.log( | |
`Message ${uid} matches the subject of "${searchString}"` | |
); | |
return uid; | |
}) | |
) | |
.then((mailUids: number[]) => { | |
console.log( | |
`Found ${mailUids.length} messages to clean up on ${squirrelMailServer}` | |
); | |
if (mailUids.length <= 0) { | |
return true; // Nothing to delete! Already cleaned up | |
} | |
mailUids.sort(); | |
const purgeRange = mailUids.join(','); | |
console.log('Deleting message range: ', purgeRange); | |
// Pass in the list of matching email uids to delete | |
return client.deleteMessages('INBOX', purgeRange, { byUid: true }); | |
}) | |
.then(() => { | |
client.close(); | |
return true; | |
}) | |
.catch((err: Error) => { | |
client.close(); | |
console.error(error, err); | |
return false; | |
}); | |
}; | |
return { | |
teardownMatchingEmails, | |
// ...Other task functions | |
}; | |
}; | |
export default Squirrelmail(); | |
// plugins/index.ts | |
on('task', { | |
/** | |
* Deletes emails with matching subject line from user's squirrelmail inbox | |
* @param {string} user - squirrelmail username i.e. squirrelmailuser | |
* @param {string} pass - squirrelmail password i.e. squirrelmailpassword | |
* @param {string} searchString - subject i.e. "Can you help install these DNS records?" | |
* @returns {bool} - returns true upon successful teardown of matching emails; otherwise, returns false on error | |
*/ | |
teardownMatchingEmails: ({ | |
user, | |
pass, | |
searchString, | |
}: { | |
user: string; | |
pass: string; | |
searchString: string; | |
}) => Squirrelmail.teardownMatchingEmails(user, pass, searchString), | |
}); | |
// Somewhere in our index.d.ts type definition file | |
task( | |
event: 'teardownMatchingEmails', | |
arg: { | |
user: string; | |
pass: string; | |
searchString: string; | |
}, | |
options?: Partial<Loggable & Timeoutable> | |
): Chainable<boolean>; | |
// Somewhere in a spec.ts file | |
cy.task("teardownMatchingEmails", { | |
user: "squirrelmailusername", | |
pass: "squirrelmailpassword", | |
searchString: "subject line", | |
}).then((matchingEmailsDeleted) => { | |
if (matchingEmailsDeleted) { | |
cy.log("Deleted matching emails from inbox!"); | |
} else { | |
cy.log("Failed to delete matching emails!"); | |
} | |
}); |
// in an emailjs-imap-client.d.ts so we can type the imap client functions ourselves | |
declare module 'emailjs-imap-client'; | |
// squirrelmail.ts (where we define all of our email related task functions | |
import ImapClient, { LOG_LEVEL_NONE as none } from 'emailjs-imap-client'; | |
const Squirrelmail = () => { | |
const squirrelmailServer = "somesquirrelmailserver.net" | |
const port = 993; | |
// ...Other email related functions | |
// Poll for a given asynchronous function that returns a Promise | |
// over a certain timeout and check interval | |
function poll( | |
asyncFn: () => Promise<boolean>, | |
timeout: number = 10000, | |
interval: number = 5000 | |
) { | |
const endTime = Number(new Date()) + timeout; | |
const checkCondition = ( | |
resolve: (value: true) => void, | |
reject: (value: false) => void | |
) => { | |
const asyncPromise = asyncFn(); | |
asyncPromise | |
.then((isSuccess) => { | |
const resolveTime = Number(new Date()); | |
// If the condition is met, we're done! | |
if (isSuccess) { | |
resolve(true); | |
} | |
// If the condition isn't met but the timeout hasn't elapsed, go again | |
else if (resolveTime < endTime) { | |
setTimeout(checkCondition, interval, resolve, reject); | |
} | |
// Didn't match and too much time, reject false | |
else { | |
console.error( | |
`Failed to satisfy checkCondition in timeout duration` | |
); | |
reject(false); | |
} | |
}) | |
.catch(() => { | |
const resolveTime = Number(new Date()); | |
if (resolveTime < endTime) { | |
setTimeout(checkCondition, interval, resolve, reject); | |
} else { | |
console.error( | |
`Failed to satisfy checkCondition in timeout duration` | |
); | |
reject(false); | |
} | |
}); | |
}; | |
return new Promise(checkCondition); | |
} | |
const findMatchingMessage = ( | |
user: string, | |
pass: string, | |
searchString: string | |
) => { | |
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // So we don't instafail because self-signed cert | |
const client = new ImapClient(squirrelMailServer, port, { | |
auth: { | |
user, | |
pass, | |
}, | |
logLevel: none, | |
}); | |
return client | |
.connect() | |
.then(() => | |
client.search( | |
'inbox', | |
{ | |
header: ['subject', searchString], | |
}, | |
{ byUid: true } | |
) | |
) | |
.then((matchedEmails?: number[]) => { | |
console.log('Matched emails: ', matchedEmails); | |
if (matchedEmails && matchedEmails.length > 0) { | |
console.log(`A message has arrived matching "${searchString}"`); | |
return true; | |
} | |
throw new Error(`No messages matched "${searchString}" yet`); | |
}) | |
.then((result: boolean) => { | |
client.close(); | |
return result; | |
}) | |
.catch((err: Error) => { | |
client.close(); | |
console.error(error, err); | |
return false; | |
}); | |
}; | |
const awaitEmail = ( | |
user: string, | |
pass: string, | |
searchString: string, | |
timeoutInMs: number, | |
checkIntervalInMs: number | |
) => | |
poll( | |
() => findMatchingMessage(user, pass, searchString), | |
timeoutInMs, | |
checkIntervalInMs | |
); | |
return { | |
awaitEmail, | |
// ...Other task functions | |
}; | |
}; | |
export default Squirrelmail(); | |
// plugins/index.ts - where we define our task functions | |
on('task', { | |
/** | |
* Awaits matching email to arrive in squirrelmail inbox within timeout specified | |
* @param {string} user - squirrelmail username i.e. squirrelmailusername | |
* @param {string} pass - squirrelmail password i.e. squirrelmailpassword | |
* @param {string} searchString - subject search string i.e. "Can you help install these DNS records?" | |
* @param {integer} timeoutInMs - how long to wait for email to arrive in milliseconds i.e. 30000 => 30s | |
* @param {integer} checkIntervalInMs - how often to check for email in inbox i.e. 5000 => every 5s | |
* @returns {bool} - returns true if matching email arrived within timeout; otherwise, returns false on error | |
*/ | |
awaitEmailInSquirrelmailInbox: ({ | |
user, | |
pass, | |
searchString, | |
timeoutInMs, | |
checkIntervalInMs, | |
}: { | |
user: string; | |
pass: string; | |
searchString: string; | |
timeoutInMs: number; | |
checkIntervalInMs: number; | |
}) => | |
Squirrelmail.awaitEmail(user, pass, searchString, timeoutInMs, checkIntervalInMs), | |
}); | |
// Somewhere in our index.d.ts type definition file | |
task( | |
event: 'awaitEmailInSquirrelmailInbox', | |
arg: { | |
user: string; | |
pass: string; | |
searchString: string; | |
timeoutInMs: number; | |
checkIntervalInMs: number; | |
}, | |
options?: Partial<Loggable & Timeoutable> | |
): Chainable<boolean>; | |
// Somewhere in a spec.ts file | |
cy.task("awaitEmailInSquirrelmailInbox", { | |
user: "squirrelmailusername", | |
pass: "squirrelmailpassword", | |
searchString: "subject line", | |
timeoutInMs: 120000, | |
checkIntervalInMs: 15000, | |
}).then((isEmailInInbox) => { | |
if (isEmailInInbox) { | |
cy.log("Email arrived in user's inbox!"); | |
} else { | |
cy.log("Failed to receive email in user's inbox in time!"); | |
} | |
}); |
// in an emailjs-imap-client.d.ts so we can type the imap client functions ourselves | |
declare module 'emailjs-imap-client'; | |
// squirrelmail.ts (where we define all of our email related task functions | |
import ImapClient, { LOG_LEVEL_NONE as none } from 'emailjs-imap-client'; | |
const Squirrelmail = () => { | |
const squirrelmailServer = "somesquirrelmailserver.net" | |
const port = 993; | |
// ...Other email related functions | |
const searchBySubject = ( | |
user: string, | |
pass: string, | |
searchString: string, | |
sinceNumDaysAgo: number // To focus our search for emails within a certain number of days ago | |
) => { | |
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // So we don't instafail because self-signed cert | |
const currentDate = new Date(); | |
const sinceDate = new Date( | |
currentDate.setDate(currentDate.getDate() - sinceNumDaysAgo) | |
); | |
// Make sure date object has time set to 0 i.e. .setHours(0,0,0,0) | |
// or else it will have invalid search parameter errors | |
sinceDate.setHours(0, 0, 0, 0); | |
const client = new ImapClient(squirrelMailServer, port, { | |
auth: { | |
user, | |
pass, | |
}, | |
logLevel: none, | |
}); | |
return client | |
.connect() | |
.then(() => | |
client.search( | |
'inbox', | |
{ | |
header: ['subject', searchString], | |
since: sinceDate, | |
}, | |
{ byUid: true } | |
) | |
) | |
.then((matchedEmails: number[]) => { | |
console.log( | |
`${matchedEmails.length} messages matches the subject of "${searchString}"` | |
); | |
if (matchedEmails.length > 0) { | |
// If there are multiple matched emails, let's return the latest one | |
return matchedEmails[matchedEmails.length - 1]; | |
} | |
throw new Error(`No messages match the subject of "${searchString}"`); | |
}) | |
.then((latestMatchedEmail: string) => { | |
console.log('Latest matched email: ', latestMatchedEmail); | |
return client.listMessages('inbox', latestMatchedEmail, ['body[]'], { | |
byUid: true, | |
}); | |
}) | |
.then( | |
( | |
listedEmail: { | |
'body[]': string; | |
}[] | |
) => { | |
if (listedEmail.length > 0) { | |
return listedEmail[0]['body[]']; | |
} | |
throw new Error('Failed to list out email body'); | |
} | |
) | |
.then((emailBody: string) => { | |
client.close(); | |
return emailBody; | |
}) | |
.catch((err: Error) => { | |
console.log('Error: ', err); | |
return ''; | |
}); | |
}; | |
return { | |
searchBySubject, | |
// ...Other task functions | |
}; | |
}; | |
export default Squirrelmail(); | |
// plugins/index.ts - where we define our task functions | |
on('task', { | |
/** | |
* Searches for latest matching email by subject in squirrelmail inbox and returns raw email body if found | |
* @param {string} user - squirrelmail username i.e. squirrelmailuser | |
* @param {string} pass - squirrelmail password i.e. squirrelmailpassword | |
* @param {string} searchString - subject search string i.e. "Your email activity export" | |
* @param {integer} sinceNumDaysAgo - search for email since a certain number of days ago | |
* @returns {string} - raw email body string of latest email matching subject or empty string if failed to retrieve email in any way | |
*/ | |
squirrelmailSearchBySubject: ({ | |
user, | |
pass, | |
searchString, | |
sinceNumDaysAgo, | |
}: { | |
user: string; | |
pass: string; | |
searchString: string; | |
sinceNumDaysAgo: number; | |
}) => | |
Squirrelmail.searchBySubject(user, pass, searchString, sinceNumDaysAgo), | |
}); | |
// Somewhere in our index.d.ts type definition file | |
task( | |
event: 'squirrelmailSearchBySubject', | |
arg: { | |
user: string; | |
pass: string; | |
searchString: string; | |
sinceNumDaysAgo: number; | |
}, | |
options?: Partial<Loggable & Timeoutable> | |
): Chainable<string>; | |
// Somewhere in a spec.ts file | |
cy.task("squirrelmailSearchBySubject", { | |
user: "squirrelmailusername", | |
pass: "squirrelmailpassword", | |
searchString: "subject line", | |
sinceNumDaysAgo: 1, | |
}).then((rawEmailBody) => { | |
if (rawEmailBody) { | |
cy.log("Matching email body contents!", rawEmailBody); | |
} else { | |
cy.log("Failed to find matching email and extract its body contents!"); | |
} | |
}); |
// In some page_object.ts | |
// For this scenario, we have a Cypress test that triggers a download email to be sent to the user's inbox after performing | |
// some UI steps on the page i.e. clicking a button on the page to export data to a CSV for us to download | |
// We need to follow the download link in the email back to the web app we own and control to continue the test | |
redirectToCSVDownloadPageFromEmail({ | |
user, | |
pass, | |
searchString, | |
}: { | |
user: string; | |
pass: string; | |
searchString: string; | |
}) { | |
// First, wait for export email to arrive in squirrelmail inbox | |
const squirrelmailTimeoutInMs = 120000; | |
const checkIntervalInMs = 15000; | |
// This wraps our cy.task("awaitEmailInSquirrelmailInbox") function call | |
return this.awaitEmailInSquirrelmailInbox({ | |
user, | |
pass, | |
searchString, | |
timeoutInMs: squirrelmailTimeoutInMs, | |
checkIntervalInMs, | |
}).then(() => | |
// We find the matching email and extract its email body string | |
cy | |
.task('squirrelmailSearchBySubject', { | |
user, | |
pass, | |
searchString, | |
// CSV downloads are valid for up to 3 days | |
// We'll check since two days ago to be safely within 0-3 days range | |
sinceNumDaysAgo: 2, | |
}) | |
.then((rawEmailBody) => { | |
if (rawEmailBody) { | |
return this.parseDownloadLink(rawEmailBody); | |
} | |
cy.log( | |
'Failed to retrieve latest matching email by subject due to some error' | |
); | |
throw new Error('Test failed to get to CSV download page'); | |
}) | |
// Make an HTTP request to the download link that we find in the email body | |
.then((downloadLink) => this.requestCSVDownload(downloadLink)) | |
// Extract out the 302 redirect path for us to visit back to our web app | |
.then((results) => this.redirectToCSVDownloadPage(results)) | |
); | |
} | |
parseDownloadLink(rawEmailBody: string) { | |
// Parse out the HTML part of the email body for us to look through | |
let flattenedMIMEBody = rawEmailBody.replace(/(=\r\n)/g, ''); | |
flattenedMIMEBody = flattenedMIMEBody.replace(/(=\n)/g, ''); | |
flattenedMIMEBody = flattenedMIMEBody.replace(/(=3D)/g, '='); | |
const startOfHTML = flattenedMIMEBody.indexOf('<html>'); | |
const endOfHTML = flattenedMIMEBody.indexOf('</html>') + '</html>'.length; | |
const emailHTML = flattenedMIMEBody.slice(startOfHTML, endOfHTML); | |
// Using cheerio, we can load up the HTML string of the email body | |
// and then easily filter through the elements for the Download link with jQuery-like functions | |
const $ = cheerio.load(emailHTML); | |
const downloadTag = $('a').filter( | |
(i, aTag) => $(aTag).text() === 'Download' | |
); | |
const downloadLink = downloadTag.attr('href') || ''; | |
return downloadLink; | |
} | |
// We make an HTTP request to the link that looks like "...sendgrid.net..." or "brandedlink.com" | |
requestCSVDownload(downloadLink: string) { | |
return cy.request(downloadLink); | |
} | |
// After making the HTTP request to the download link, we extract out the redirect path for | |
// us to cy.visit() back to our web app | |
redirectToCSVDownloadPage(results: any) { | |
const redirect = results.redirects[0]; | |
// String looks like 302: https://staging.app.com/download/path | |
const [redirectStatus, redirectRoute] = redirect.split(' '); | |
// We extract out the /download/path plus any query params after it | |
const redirectURL = new URL(redirectRoute); | |
const csvDownloadPath = `${redirectURL.pathname}${redirectURL.search}`; | |
// cy.visit(/download/path?token=<token>) to go back to our web app on the same superdomain | |
return cy.visit(csvDownloadPath); | |
} |