Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/screens/mail/EmailDetailScreen/EmailBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ export const EmailBody = ({ message, bodySource }: { message: EmailResponse; bod

const areImagesBlocked = !areRemoteImagesAllowed && body.hasImagesHostedElsewhere;

if (bodySource.type === 'encryptedUnreadable') {
return (
<View
style={[tailwind('flex-row items-center rounded-lg px-3 py-3'), { backgroundColor: getColor('bg-gray-5') }]}
>
<AppText style={[tailwind('flex-1 text-sm'), { color: getColor('text-gray-60') }]}>
{strings.screens.mail.unableToDecryptPreview}
</AppText>
</View>
);
}

return (
<View>
{areImagesBlocked && (
Expand Down
61 changes: 60 additions & 1 deletion src/services/mail/emailBody/emailBodyContent.spec.ts
Original file line number Diff line number Diff line change
@@ -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>): EmailResponse => ({ ...fields }) as EmailResponse;

Expand Down Expand Up @@ -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: '<p>Hello <b>there</b></p>' })).toEqual({
content: '<p>Hello <b>there</b></p>',
isHtml: true,
});
});

test('when a message was encrypted and could not be decrypted, then nothing is displayed', () => {
const message = anEmail({ htmlBody: '<p>An unreadable copy</p>', textBody: 'an encrypted payload' });

Expand Down Expand Up @@ -130,3 +139,53 @@ describe('Knowing whether a message would reach out for its images', () => {
expect(hasRemoteImages('<a href="https://somewhere-else.example">Our website</a>')).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('<p>Hello there</p>')).toBe(true);
});

test('when the body opens with blank space before its first tag, then it is still read as formatted', () => {
expect(isMarkupBody('\n <div>Hello there</div>')).toBe(true);
});

test('when the body is a whole document, then it is read as formatted', () => {
expect(isMarkupBody('<!DOCTYPE html><html><body>Hello there</body></html>')).toBe(true);
});

test('when the body carries a closing tag, then it is read as formatted', () => {
expect(isMarkupBody('Here are the numbers</p>')).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<br>Here are the numbers')).toBe(true);
});

test('when the body opens with a comment, then it is read as formatted', () => {
expect(isMarkupBody('<!-- written elsewhere --><p>Here are the numbers</p>')).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<p>Here are the numbers</p>')).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 <whatever element for this')).toBe(false);
});

test('when the body is plain text with arrows in it, then it is not read as formatted', () => {
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);
});
});
37 changes: 32 additions & 5 deletions src/services/mail/emailBody/emailBodyContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,37 @@
'\'': '&#39;',
};

/**
* 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 = /<img\b[^>]*\ssrc\s*=\s*["']https?:/i;
const OPENING_MARKUP_PATTERN = /^(?:<!doctype\s|<!--|<\?|<[a-z][a-z0-9]*(?:\s[^>]*)?\/?>)/i;
const EMBEDDED_MARKUP_PATTERN = /<\/[a-z][a-z0-9]*\s*>|<(?:br|hr|img|p|div|table|tr|td|ul|ol|li)\b[^>]*>/i;

Check warning on line 34 in src/services/mail/emailBody/emailBodyContent.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Simplify this regular expression to reduce its complexity from 21 to the 20 allowed.

See more on https://sonarcloud.io/project/issues?id=internxt_drive-mobile&issues=AaCQtAHLixRLzS7jS4ZF&open=AaCQtAHLixRLzS7jS4ZF&pullRequest=591
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.
Expand All @@ -32,7 +62,7 @@
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':
Expand All @@ -52,10 +82,7 @@
* @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 `<div style="white-space:pre-wrap">${escapedText}</div>`;
};
export const plainTextToHtml = (text: string): string => `<div style="white-space:pre-wrap">${escapeHtml(text)}</div>`;

/**
* Produces the markup of a message that is safe to display, whichever of its bodies is used.
Expand Down
48 changes: 48 additions & 0 deletions src/services/mail/mailCrypto.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ramon@inxt.eu>',
});

const encryptedEmail = encryptMock.mock.calls[0][0];
expect(encryptedEmail.text).toContain('Write to Ramon &lt;ramon@inxt.eu&gt;');
});

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 }]);

Expand Down Expand Up @@ -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 });

Expand Down
17 changes: 10 additions & 7 deletions src/services/mail/mailCrypto.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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<EncryptedMessagePayload> => {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -483,7 +486,7 @@ export const encryptAndSendReply = async ({
const { encryption, attachments } = await encryptMessageForRecipients({
allAddresses,
activeDomains,
text,
body: plainTextToHtml(text),
preview: previewOf(text),
files,
});
Expand Down
Loading