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('\uFEFF

Here 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, });