Parse Email Url

How to parse the email protocol URL the application opens to send an email.

Imagine the application opens your email client and sends a message. This is possible by opening a browser window with an email url, something like mailto:recipient?subject=...&body=... In this blog post I will show how you can parse such url and confirm something in the text of the message.

🎁 You can find the tested example used in this blog post in the recipe "Parse Email URL" on my Cypress examples site.

Here is our application code. The app uses window.open method to create a popup window. A typical browser would then prompt the user to send the pre-filled email.

1
<button id="email">Share link</button>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
document
.getElementById('email')
.addEventListener('click', () => {
const params = new URLSearchParams()
params.append('subject', 'Use this promotion code')
params.append(
'body',
[
'Hello, friend',
'Here is your link to get a discount',
'',
'https://acme.co/discount/GLEB10OFF',
].join('\r'),
)
const emailUrl = 'mailto:recipient?' + params.toString()
window.open(
emailUrl,
'email-popup',
'popup,width=300,height=300',
)
})

I have described how to deal with window.open method call in my previous blog post Deal with Second Tab in Cypress. Let's do it:

1
2
3
4
5
6
// prepare for the "window.open" to be called
cy.window().then((win) => {
cy.stub(win, 'open').as('open')
})
// click on the button, which will execute "window.open"
cy.contains('button', 'Share link').click()

Button click calls the window.open stub

Notice how the parameters in the email url are URL-encoded by the URLSearchParams:

1
2
mailto:recipient?subject=Use+this+promotion+code&body=Hello%2C+friend%0DHere+is+your+link+to+get+a
+discount%0D%0Dhttps%3A%2F%2Facme.co%2Fdiscount%2FGLEB10OFF

Let's get this string from the window.open Sinon stub

1
2
3
4
5
cy.get('@open')
.should('have.been.calledOnceWith', Cypress.sinon.match.string)
.its('firstCall.args.0')
// confirm the URL is an e mail link
.should('match', /^mailto:recipient\?/)

Get the first argument of the window.open call

We want the search parameters after the "?" character. Let's use browser API URLSearchParams to parse and escape:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
cy.get('@open')
.should('have.been.calledOnceWith', Cypress.sinon.match.string)
.its('firstCall.args.0')
// confirm the URL is an e mail link
.should('match', /^mailto:recipient\?/)
// grab the search arguments after the "?"
.invoke('split', '?')
.its(1)
// parse the search arguments using URLSearchParams API
.then((s) => new URLSearchParams(s))
.then((params) => {
// confirm individual fields
// notice that values are already decoded by the URLSearchParams
expect(params.get('subject'), 'subject').to.equal(
'Use this promotion code',
)
// let's work with the "body" text
return params.get('body')
})
.should('be.a', 'string')

Parse the email url parameters

Let's find the shared url in the email body, which is at the last line. We can split the text and get the last line

1
2
3
4
5
6
7
8
9
10
11
12
...
.should('be.a', 'string')
// split the text into individual lines if needed
.invoke('split', '\r')
// the last line is the invite link
.at(-1)
// confirm it is a HTTPS link
.should('match', /^https:\/\//)
// the discount code is the last part of the URL in our case
.invoke('split', '/')
.at(-1)
.should('equal', 'GLEB10OFF')

Find the discount code in the email body

Tip: I like using a chain of commands, since they are so easy to debug. Just click on any command and see the details in the DevTools console. For example, what did invoke .split() do? What did it split? What did it yield to the next command .at(-1)? Let's click and find out!

Debugging the cy.invoke "split" command

Separate chains

The chain of commands is a little too long. We can split it up by saving intermediate subjects using Cypress aliases and write simpler code using cypress-map helpers.

Our test starts the same way and stores the email URL params in an alias

1
2
3
4
5
6
7
8
9
10
11
12
13
14
cy.window().then((win) => {
cy.stub(win, 'open').as('open')
})
cy.contains('button', 'Share link').click()
// get the email URL parameters
cy.get('@open')
.should('have.been.calledOnce')
.its('firstCall.args.0')
.should('match', /^mailto:recipient\?/)
// grab the search arguments after the "?"
.invoke('split', '?')
.its(1)
// store the encoded search params string in an alias
.as('params')

We now have the encoded URL search parameters in an alias params. We can convert it to a plain object via URLSearchParams API.

1
2
3
4
5
6
7
cy.get('@params')
.make(URLSearchParams)
.toPlainObject('entries')
.should('have.keys', ['subject', 'body'])
.as('email')
// confirm the email subject text
.and('have.property', 'subject', 'Use this promotion code')

Get the email body and extract the HTTPS link. Imagine the link could be anywhere, so let's use a regular expression with a named capture group.

1
2
3
4
5
6
7
cy.get('@email')
.its('body')
.should('be.a', 'string')
.invoke('match', /(?<link>https:\/\/\S+)/)
.its('groups.link')
.should('be.a', 'string')
.as('link')

Now we can validate the link or visit it. Let's confirm the text GLEB10OFF is one of the path segments

1
2
3
cy.get('@link')
.invoke('split', '/')
.should('include', 'GLEB10OFF')

The test passes

Refactored test using aliases

Nice.