From efc5f28d3ec751729a4990c12ab27e92527f7616 Mon Sep 17 00:00:00 2001
From: Ramon Candel
Date: Fri, 11 Sep 2026 15:31:26 +0200
Subject: [PATCH] Send the body of an email as markup instead of plain text
---
.../mail/EmailDetailScreen/EmailBody.tsx | 12 ++++
.../mail/emailBody/emailBodyContent.spec.ts | 61 ++++++++++++++++++-
.../mail/emailBody/emailBodyContent.ts | 37 +++++++++--
src/services/mail/mailCrypto.service.spec.ts | 48 +++++++++++++++
src/services/mail/mailCrypto.service.ts | 17 +++---
5 files changed, 162 insertions(+), 13 deletions(-)
diff --git a/src/screens/mail/EmailDetailScreen/EmailBody.tsx b/src/screens/mail/EmailDetailScreen/EmailBody.tsx
index 996789f25..2da1ebed2 100644
--- a/src/screens/mail/EmailDetailScreen/EmailBody.tsx
+++ b/src/screens/mail/EmailDetailScreen/EmailBody.tsx
@@ -63,6 +63,18 @@ export const EmailBody = ({ message, bodySource }: { message: EmailResponse; bod
const areImagesBlocked = !areRemoteImagesAllowed && body.hasImagesHostedElsewhere;
+ if (bodySource.type === 'encryptedUnreadable') {
+ return (
+
+
+ {strings.screens.mail.unableToDecryptPreview}
+
+
+ );
+ }
+
return (
{areImagesBlocked && (
diff --git a/src/services/mail/emailBody/emailBodyContent.spec.ts b/src/services/mail/emailBody/emailBodyContent.spec.ts
index ed44ca8ed..50fbc3753 100644
--- a/src/services/mail/emailBody/emailBodyContent.spec.ts
+++ b/src/services/mail/emailBody/emailBodyContent.spec.ts
@@ -1,6 +1,6 @@
import { EmailResponse } from '@internxt/sdk/dist/mail/types';
-import { buildEmailBodyHtml, hasRemoteImages, plainTextToHtml, resolveEmailBody } from './emailBodyContent';
+import { buildEmailBodyHtml, hasRemoteImages, isMarkupBody, plainTextToHtml, resolveEmailBody } from './emailBodyContent';
const anEmail = (fields: Partial): EmailResponse => ({ ...fields }) as EmailResponse;
@@ -32,6 +32,15 @@ describe('Choosing which body of a message to display', () => {
});
});
+ test('when a decrypted message was written with formatting, then it is displayed with that formatting', () => {
+ const message = anEmail({ htmlBody: null, textBody: 'an encrypted payload' });
+
+ expect(resolveEmailBody(message, { type: 'decrypted', text: 'Hello there
' })).toEqual({
+ content: 'Hello there
',
+ isHtml: true,
+ });
+ });
+
test('when a message was encrypted and could not be decrypted, then nothing is displayed', () => {
const message = anEmail({ htmlBody: 'An unreadable copy
', textBody: 'an encrypted payload' });
@@ -130,3 +139,53 @@ describe('Knowing whether a message would reach out for its images', () => {
expect(hasRemoteImages('Our website')).toBe(false);
});
});
+
+describe('Telling whether a decrypted body was written with formatting', () => {
+ test('when the body opens with a tag, then it is read as formatted', () => {
+ expect(isMarkupBody('Hello there
')).toBe(true);
+ });
+
+ test('when the body opens with blank space before its first tag, then it is still read as formatted', () => {
+ expect(isMarkupBody('\n Hello there
')).toBe(true);
+ });
+
+ test('when the body is a whole document, then it is read as formatted', () => {
+ expect(isMarkupBody('Hello there')).toBe(true);
+ });
+
+ test('when the body carries a closing tag, then it is read as formatted', () => {
+ expect(isMarkupBody('Here are the numbers
')).toBe(true);
+ });
+
+ test('when the body is plain text, then it is not read as formatted', () => {
+ expect(isMarkupBody('Hello there')).toBe(false);
+ });
+
+ test('when the body is plain text that opens with a comparison, then it is not read as formatted', () => {
+ expect(isMarkupBody('5 < 7 is true')).toBe(false);
+ });
+
+ test('when the body opens with a line of text before its first tag, then it is read as formatted', () => {
+ expect(isMarkupBody('Hi there
Here are the numbers')).toBe(true);
+ });
+
+ test('when the body opens with a comment, then it is read as formatted', () => {
+ expect(isMarkupBody('Here are the numbers
')).toBe(true);
+ });
+
+ test('when the body opens with a character the editor left in front of its markup, then it is read as formatted', () => {
+ expect(isMarkupBody('\uFEFFHere are the numbers
')).toBe(true);
+ });
+
+ test('when the body is plain text that names a tag in passing, then it is not read as formatted', () => {
+ expect(isMarkupBody('Use the {
+ expect(isMarkupBody('a -> b, and 5 < 7 > 3')).toBe(false);
+ });
+
+ test('when the body is empty, then it is not read as formatted', () => {
+ expect(isMarkupBody('')).toBe(false);
+ });
+});
diff --git a/src/services/mail/emailBody/emailBodyContent.ts b/src/services/mail/emailBody/emailBodyContent.ts
index e5f497a27..e4716b923 100644
--- a/src/services/mail/emailBody/emailBodyContent.ts
+++ b/src/services/mail/emailBody/emailBodyContent.ts
@@ -20,7 +20,37 @@ const HTML_ENTITY_BY_CHARACTER: Record = {
'\'': ''',
};
+/**
+ * Turns text into markup that shows it as written, with nothing in it read as markup.
+ *
+ * @param text the text to show as written
+ * @returns the same text with every character that means something in markup replaced
+ */
+export const escapeHtml = (text: string): string =>
+ text.replace(/[&<>"']/g, (character) => HTML_ENTITY_BY_CHARACTER[character]);
+
const REMOTE_IMAGE_PATTERN = /
]*\ssrc\s*=\s*["']https?:/i;
+const OPENING_MARKUP_PATTERN = /^(?:]*)?\/?>)/i;
+const EMBEDDED_MARKUP_PATTERN = /<\/[a-z][a-z0-9]*\s*>|<(?:br|hr|img|p|div|table|tr|td|ul|ol|li)\b[^>]*>/i;
+const LEADING_BLANKS_PATTERN = /^[\s\uFEFF\u200B]+/;
+
+/**
+ * Tells whether the body of a message was written as markup. The envelope of an encrypted message
+ * carries a single body and says nothing about its format, so the only way to know is to look at
+ * it: `mail-web` writes markup there, and the mobile compose writes plain text. A body that opens
+ * with a tag is markup, and so is one that carries a closing or a standalone tag further in, which
+ * is what a message that opens with a line of text looks like.
+ *
+ * @param body the body of the message, as it was decrypted
+ * @returns true when the body has to be read as markup
+ */
+export const isMarkupBody = (body: string): boolean => {
+ const bodyWithoutLeadingBlanks = body.replace(LEADING_BLANKS_PATTERN, '');
+
+ return (
+ OPENING_MARKUP_PATTERN.test(bodyWithoutLeadingBlanks) || EMBEDDED_MARKUP_PATTERN.test(bodyWithoutLeadingBlanks)
+ );
+};
/**
* Picks which of the bodies of a message has to be displayed.
@@ -32,7 +62,7 @@ const REMOTE_IMAGE_PATTERN = /
]*\ssrc\s*=\s*["']https?:/i;
export const resolveEmailBody = (message: EmailResponse, source: EmailBodySource): EmailBodyContent => {
switch (source.type) {
case 'decrypted':
- return { content: source.text, isHtml: false };
+ return { content: source.text, isHtml: isMarkupBody(source.text) };
case 'encryptedUnreadable':
return { content: '', isHtml: false };
case 'plain':
@@ -52,10 +82,7 @@ export const resolveEmailBody = (message: EmailResponse, source: EmailBodySource
* @param text the body of the message, as plain text
* @returns markup that shows the text with its line breaks and no character read as markup
*/
-export const plainTextToHtml = (text: string): string => {
- const escapedText = text.replace(/[&<>"']/g, (character) => HTML_ENTITY_BY_CHARACTER[character]);
- return `${escapedText}
`;
-};
+export const plainTextToHtml = (text: string): string => `${escapeHtml(text)}
`;
/**
* Produces the markup of a message that is safe to display, whichever of its bodies is used.
diff --git a/src/services/mail/mailCrypto.service.spec.ts b/src/services/mail/mailCrypto.service.spec.ts
index cb1e0fc18..83a9c02fc 100644
--- a/src/services/mail/mailCrypto.service.spec.ts
+++ b/src/services/mail/mailCrypto.service.spec.ts
@@ -105,6 +105,46 @@ describe('Sending an encrypted email', () => {
expect(keyAsText(wrappedFor('someone@gmail.com'))).toBe(SERVER_PUBLIC_KEY);
});
+ test('when a message is written on several lines, then it travels as markup, so no reader collapses it into one line', async () => {
+ getPublicKeysMock.mockResolvedValue([{ address: 'friend@inxt.me', publicKey: 'friend-key' }]);
+
+ await encryptAndSendEmail({
+ to: ['friend@inxt.me'],
+ subject: 'Subject',
+ text: 'Hello there,\n\nHere are the numbers',
+ });
+
+ const encryptedEmail = encryptMock.mock.calls[0][0];
+ expect(encryptedEmail.text).toContain('white-space:pre-wrap');
+ expect(encryptedEmail.text).toContain('Hello there,\n\nHere are the numbers');
+ });
+
+ test('when a message names an address between angle brackets, then it is not read as markup by whoever displays it', async () => {
+ getPublicKeysMock.mockResolvedValue([{ address: 'friend@inxt.me', publicKey: 'friend-key' }]);
+
+ await encryptAndSendEmail({
+ to: ['friend@inxt.me'],
+ subject: 'Subject',
+ text: 'Write to Ramon ',
+ });
+
+ const encryptedEmail = encryptMock.mock.calls[0][0];
+ expect(encryptedEmail.text).toContain('Write to Ramon <ramon@inxt.eu>');
+ });
+
+ test('when a message is written on several lines, then the mailbox list still shows its opening as plain text', async () => {
+ getPublicKeysMock.mockResolvedValue([{ address: 'friend@inxt.me', publicKey: 'friend-key' }]);
+
+ await encryptAndSendEmail({
+ to: ['friend@inxt.me'],
+ subject: 'Subject',
+ text: 'Hello there,\n\nHere are the numbers',
+ });
+
+ const encryptedEmail = encryptMock.mock.calls[0][0];
+ expect(encryptedEmail.preview).toBe('Hello there,\n\nHere are the numbers');
+ });
+
test('when a recipient with an internal domain has no published key, then sending fails instead of delivering an unreadable message', async () => {
getPublicKeysMock.mockResolvedValue([{ address: 'friend@inxt.me', publicKey: null }]);
@@ -443,6 +483,14 @@ describe('Sending an encrypted reply', () => {
expect(replyRequestBody().to).toEqual([{ email: 'friend@inxt.me' }]);
});
+ test('when a reply is written on several lines, then it travels as markup like any other message', async () => {
+ await encryptAndSendReply({ ...reply, text: 'Sure,\n\nsee you there' });
+
+ const encryptedEmail = encryptMock.mock.calls[0][0];
+ expect(encryptedEmail.text).toContain('white-space:pre-wrap');
+ expect(encryptedEmail.text).toContain('Sure,\n\nsee you there');
+ });
+
test('when replying to everybody, then the request says so', async () => {
await encryptAndSendReply({ ...reply, replyAll: true });
diff --git a/src/services/mail/mailCrypto.service.ts b/src/services/mail/mailCrypto.service.ts
index 64519de63..87a5543c4 100644
--- a/src/services/mail/mailCrypto.service.ts
+++ b/src/services/mail/mailCrypto.service.ts
@@ -21,6 +21,7 @@ import asyncStorageService from '../AsyncStorageService';
import { logger } from '../common/logger/logger.service';
import { AcceptedEncodings, fs } from '../FileSystemService';
import { CachedDecryptedEmail, mailLocalDB } from './database/mailLocalDB';
+import { plainTextToHtml } from './emailBody/emailBodyContent';
import {
ActiveDomainsUnavailableError,
BlindCopyNotDeliverableError,
@@ -303,8 +304,10 @@ export const normalizeRecipients = ({
* @param email - The message to encrypt.
* @param email.allAddresses - Every address of the message, in any field.
* @param email.activeDomains - Domains the mail server serves, used to tell internal recipients apart.
- * @param email.text - Body of the message.
- * @param email.preview - Opening of the body, shown in the mailbox list before the message is read.
+ * @param email.body - Body of the message, as markup: every client that reads it renders it as
+ * markup, so plain text would reach them with its line breaks collapsed.
+ * @param email.preview - Opening of the body as plain text, shown in the mailbox list before the
+ * message is read.
* @param email.files - Attachments to encrypt and upload.
* @returns The encrypted envelope of the message and its uploaded attachments.
* @throws InternxtRecipientKeyMissingError when an internal recipient publishes no key.
@@ -313,13 +316,13 @@ export const normalizeRecipients = ({
export const encryptMessageForRecipients = async ({
allAddresses,
activeDomains,
- text,
+ body,
preview,
files = [],
}: {
allAddresses: string[];
activeDomains: ActiveDomain[];
- text: string;
+ body: string;
preview: string;
files?: MailAttachment[];
}): Promise => {
@@ -349,7 +352,7 @@ export const encryptMessageForRecipients = async ({
);
}
- const email: Email = { text, preview, attachmentsSessionKey };
+ const email: Email = { text: body, preview, attachmentsSessionKey };
const { encryptedKeys, encEmail } = await encryptEmailHybridForMultipleRecipients(email, recipients);
return {
@@ -426,7 +429,7 @@ export const encryptAndSendEmail = async ({ to, cc, bcc, subject, text, files }:
const { encryption, attachments } = await encryptMessageForRecipients({
allAddresses,
activeDomains,
- text,
+ body: plainTextToHtml(text),
preview: previewOf(text),
files,
});
@@ -483,7 +486,7 @@ export const encryptAndSendReply = async ({
const { encryption, attachments } = await encryptMessageForRecipients({
allAddresses,
activeDomains,
- text,
+ body: plainTextToHtml(text),
preview: previewOf(text),
files,
});