blog
How to Create and Send HTML Email with a Plain-Text Alternative
Build matching HTML and plain-text email bodies, send them with MailSlurp, and check the received MIME message with a runnable Node.js example.

An HTML email gives a receipt or notification a clear heading and an easy-to-find link. Its plain-text alternative needs the same order status and destination, even without the formatting.
The browser preview can't tell you which parts your sending service put into the email. Check the received message for a missing text alternative, an outdated order status or a link pointing to the wrong destination. Then check how it looks in email clients.
MailSlurp provides programmable email inboxes for sending and receiving test messages. The Node.js example below creates a temporary sender and recipient, sends both versions, then retrieves the raw received email to check its MIME structure and content.
Create two versions of the same confirmation
Let's use a fictional order confirmation for Ari. The HTML should show a heading, the sentence "Order A1042 is confirmed." and a receipt link. The text version should preserve the greeting, order status and full receipt URL. The example.com address is a placeholder, not a working order page.
A message with these alternative representations uses multipart/alternative. Put the plain-text part first and HTML second. The parts express the same information; a mail client chooses a supported representation. This ordering follows RFC 2046's alternative-body format.
The example supplies both entries in bodyParts, with explicit text/plain and text/html content types. It leaves body unset. For a single HTML body, the simpler body plus isHTML: true form is covered in the MailSlurp JavaScript sending reference.
Send the example and inspect its MIME
Use Node.js 22 or later in a server-side project. Install the versions used by this example:
npm install mailslurp-client@17.4.0 mailparser@3.9.39
Save the following as html-message.mjs. The HTML uses a single presentation table and inline styling; the content remains ordinary text and a link, with no image required to understand the confirmation.
import assert from 'node:assert/strict';
import { simpleParser } from 'mailparser';
import { MatchOptionFieldEnum, MatchOptionShouldEnum } from 'mailslurp-client';
const subject = 'Order A1042 confirmed';
const receiptUrl = 'https://example.com/receipts/A1042';
const text = `Thanks for your order, Ari.
Order A1042 is confirmed.
View your receipt: ${receiptUrl}`;
const html = `<!doctype html>
<html lang="en">
<head><meta charset="UTF-8"><title>Order confirmation</title></head>
<body style="margin:0;background:#ffffff;color:#172033;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr><td style="padding:24px;font-family:Arial,sans-serif;line-height:1.5;">
<h1 style="font-size:24px;">Thanks for your order, Ari.</h1>
<p>Order A1042 is confirmed.</p>
<p><a href="${receiptUrl}" style="color:#155eef;">View your receipt</a></p>
</td></tr>
</table>
</body>
</html>`;
export async function sendHtmlExample(mailslurp) {
const inboxIds = [];
try {
const create = async () => {
const inbox = await mailslurp.inboxController.createInboxWithOptions({
createInboxDto: { expiresIn: 600_000 },
});
inboxIds.push(inbox.id);
return inbox;
};
const sender = await create();
const recipient = await create();
const since = new Date();
const sent = await mailslurp.sendEmail(sender.id, {
to: [recipient.emailAddress],
subject,
bodyParts: [
{ contentType: 'text/plain; charset=UTF-8', contentBody: text },
{ contentType: 'text/html; charset=UTF-8', contentBody: html },
],
});
const received = await mailslurp.waitController.waitForMatchingFirstEmail({
inboxId: recipient.id,
since,
timeout: 60_000,
unreadOnly: false,
matchOptions: { matches: [{
field: MatchOptionFieldEnum.SUBJECT,
should: MatchOptionShouldEnum.EQUAL,
value: subject,
}] },
});
const raw = await mailslurp.emailController.getRawEmailJson({
emailId: received.id,
});
const message = await simpleParser(raw.content, {
skipHtmlToText: true,
skipTextToHtml: true,
skipImageLinks: true,
});
assert.equal(message.headers.get('content-type')?.value, 'multipart/alternative');
assert.equal(message.subject, subject);
assert.ok(message.to?.value.some(value => value.address === recipient.emailAddress));
assert.equal(typeof message.text, 'string');
assert.equal(typeof message.html, 'string');
assert.equal(message.text.trim(), text);
assert.ok(message.html.includes('Order A1042 is confirmed.'));
assert.ok(message.html.includes(`href="${receiptUrl}"`));
return { sentId: sent.id, receivedId: received.id };
} finally {
const cleanup = await Promise.allSettled(
inboxIds.map(id => mailslurp.deleteInbox(id)),
);
if (cleanup.some(result => result.status === 'rejected')) {
throw new Error('Could not delete every temporary inbox. Check your account.');
}
}
}
Each inbox has a ten-minute expiry. The finally block also attempts to delete every inbox created by this run, including when sending, waiting or a content assertion fails. A cleanup failure rejects the run. The wait is limited to 60 seconds, matches the exact subject and starts from the timestamp captured before sending.
Run the check with your API key
Set MAILSLURP_API_KEY in your server or test environment. Keep it out of browser code and source control. Save this second file as run.mjs beside html-message.mjs:
import { MailSlurp } from 'mailslurp-client';
import { sendHtmlExample } from './html-message.mjs';
const apiKey = process.env.MAILSLURP_API_KEY;
if (!apiKey) throw new Error('Set MAILSLURP_API_KEY before running this example.');
try {
const result = await sendHtmlExample(new MailSlurp({ apiKey }));
console.log('Received both message versions:', result);
} catch {
console.error('The send, receive, MIME check or cleanup failed.');
process.exitCode = 1;
}
Run it from that directory:
node run.mjs
The command uses your account to send the example between two temporary MailSlurp inboxes. On success, it prints Received both message versions: with the sent and received message IDs. By then, cleanup has already removed the inboxes and messages, so you can't use those IDs to reopen the email.
A successful run checks this example's received MIME and the assertions listed below. It does not test a checkout application or open the fictional receipt page.
Understand what the result checks
The assertions require a multipart/alternative root, the expected subject and recipient, a separate text body matching the supplied text, and an HTML body containing the order confirmation and receipt destination. A successful send response can't tell you whether those details arrived intact.
The important parser option is skipHtmlToText: true. Without it, MailParser can generate text from an HTML-only message, making a missing text part look like a supplied alternative. skipTextToHtml disables generated textAsHtml, while skipImageLinks keeps any cid: image references unchanged. These options are documented by MailParser. Parsing does not sanitize HTML; if you later display received HTML in an application, handle it as untrusted content.
The runner reports one fixed failure message rather than dumping the raw response. To investigate locally, use a debugger to identify the failing call before cleanup removes the inboxes. Check account access and the API key for a send failure, the recipient and subject filter for a wait failure, and the raw message's content type and both bodies for an assertion failure. The root-type assertion is deliberately specific to this simple message without attachments; a more complex message can have a different valid MIME structure.
When adapting the example, change both bodies and their assertions together. For campaign checks involving dates, prices or personalization, the plain-text email guide shows what to compare after an edit. To retain failure information without copying full bodies or usable authentication links into shared logs, use the CI diagnostics guide.
Check the HTML in the clients you need to support
A valid MIME message can still have a layout problem. Keep essential text readable without images, use descriptive links, and test the final HTML at mobile and desktop widths. The sample's simple table and inline styles reduce the number of layout assumptions; they do not establish compatibility with every client.
Embedded CSS support varies by client and account context, as the Can I email style-element tests illustrate. Check the properties and clients your actual design uses instead of assuming that every style is stripped or that browser support carries over to email.
For appearance, follow the HTML email preview guide and send the final version to MailSlurp Device Previews. For an application workflow, also follow the real receipt or verification action and assert its result. Inbox versus spam placement is a separate measurement through inbox placement testing; neither MIME validation nor a screenshot establishes it.
Adapt the sending path without losing the checks
If your application already sends over SMTP, keep that path in the test and send to a MailSlurp recipient inbox. The Node.js SMTP guide covers transport setup; the Gmail HTML sending guide covers composing through Gmail. In either case, retrieve the delivered message and check the two bodies, then test appearance separately. Do not replace the application's actual sending path with this API example and treat that as proof of the original integration.