From 54f2a0c2da82908514d9f0a35910d1a093e7703b Mon Sep 17 00:00:00 2001 From: Ramon Candel Date: Wed, 9 Sep 2026 16:27:06 +0200 Subject: [PATCH 1/3] Render the body of an email with its format --- assets/lang/strings.ts | 4 + ios/Podfile.lock | 6 - jest.config.ts | 7 + package.json | 3 +- .../mail/EmailDetailScreen/EmailBody.tsx | 115 ++++++++ .../hooks/useEmailBodyHeight.spec.ts | 92 +++++++ .../hooks/useEmailBodyHeight.ts | 66 +++++ src/screens/mail/EmailDetailScreen/index.tsx | 44 ++-- .../mail/emailBody/emailBodyContent.spec.ts | 132 ++++++++++ .../mail/emailBody/emailBodyContent.ts | 78 ++++++ .../mail/emailBody/emailDocument.spec.ts | 69 +++++ src/services/mail/emailBody/emailDocument.ts | 46 ++++ .../mail/emailBody/sanitizeMailHtml.spec.ts | 94 +++++++ .../mail/emailBody/sanitizeMailHtml.ts | 21 ++ yarn.lock | 247 ++++++++---------- 15 files changed, 851 insertions(+), 173 deletions(-) create mode 100644 src/screens/mail/EmailDetailScreen/EmailBody.tsx create mode 100644 src/screens/mail/EmailDetailScreen/hooks/useEmailBodyHeight.spec.ts create mode 100644 src/screens/mail/EmailDetailScreen/hooks/useEmailBodyHeight.ts create mode 100644 src/services/mail/emailBody/emailBodyContent.spec.ts create mode 100644 src/services/mail/emailBody/emailBodyContent.ts create mode 100644 src/services/mail/emailBody/emailDocument.spec.ts create mode 100644 src/services/mail/emailBody/emailDocument.ts create mode 100644 src/services/mail/emailBody/sanitizeMailHtml.spec.ts create mode 100644 src/services/mail/emailBody/sanitizeMailHtml.ts diff --git a/assets/lang/strings.ts b/assets/lang/strings.ts index 0c6ccf585..0e6929e5e 100644 --- a/assets/lang/strings.ts +++ b/assets/lang/strings.ts @@ -375,6 +375,8 @@ const translations = { }, openMailboxes: 'Open mailboxes', unableToDecryptPreview: 'This message could not be decrypted', + showRemoteImages: 'Show images', + remoteImagesBlocked: 'Images in this message are blocked', }, compose_email: { title: 'New message', @@ -1433,6 +1435,8 @@ const translations = { }, openMailboxes: 'Abrir bandejas', unableToDecryptPreview: 'No se ha podido descifrar este mensaje', + showRemoteImages: 'Mostrar imágenes', + remoteImagesBlocked: 'Las imágenes de este mensaje están bloqueadas', }, compose_email: { title: 'Nuevo mensaje', diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 5213ea119..a1921ade7 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1609,8 +1609,6 @@ PODS: - Yoga - react-native-randombytes (3.6.2): - React-Core - - react-native-render-html (6.3.4): - - React-Core - react-native-safe-area-context (5.6.2): - hermes-engine - RCTRequired @@ -2549,7 +2547,6 @@ DEPENDENCIES: - react-native-pdf (from `../node_modules/react-native-pdf`) - react-native-pdf-thumbnail (from `../node_modules/react-native-pdf-thumbnail`) - react-native-randombytes (from `../node_modules/react-native-randombytes`) - - react-native-render-html (from `../node_modules/react-native-render-html`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - react-native-video (from `../node_modules/react-native-video`) - react-native-webview (from `../node_modules/react-native-webview`) @@ -2772,8 +2769,6 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-pdf-thumbnail" react-native-randombytes: :path: "../node_modules/react-native-randombytes" - react-native-render-html: - :path: "../node_modules/react-native-render-html" react-native-safe-area-context: :path: "../node_modules/react-native-safe-area-context" react-native-video: @@ -2955,7 +2950,6 @@ SPEC CHECKSUMS: react-native-pdf: 70836b3b48595c7f85a3aa2b5d122b175c455733 react-native-pdf-thumbnail: cc2076c2d871e43cf267611975db8ed3682cc65e react-native-randombytes: 4eeddad82d1fd57adda1cd5beffe67d041c9d6de - react-native-render-html: 5afc4751f1a98621b3009432ef84c47019dcb2bd react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2 react-native-video: f5982e21efab0dc356d92541a8a9e19581307f58 react-native-webview: b29007f4723bca10872028067b07abacfa1cb35a diff --git a/jest.config.ts b/jest.config.ts index 1fda8fb6a..737479ac8 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -24,6 +24,13 @@ const untranspiledModulePatterns = [ 'p-limit', 'yocto-queue', 'mime', + 'sanitize-html', + 'htmlparser2', + 'domhandler', + 'domutils', + 'dom-serializer', + 'domelementtype', + 'entities', ]; const config: Config.InitialOptions = { diff --git a/package.json b/package.json index 7a4ca9b4e..c8d08850f 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@shopify/flash-list": "2.0.2", "@testing-library/react-hooks": "^8.0.1", "@types/luxon": "^3.0.1", + "@types/sanitize-html": "^2.16.1", "@types/unorm": "^1.3.28", "async": "^3.2.6", "axios": "^1.18.1", @@ -113,7 +114,6 @@ "react-native-progress": "^5.0.0", "react-native-randombytes": "^3.6.2", "react-native-reanimated": "~4.1.1", - "react-native-render-html": "6.3.4", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-share": "^7.3.3", @@ -132,6 +132,7 @@ "redux": "^4.0.5", "reflect-metadata": "^0.2.2", "regenerator-runtime": "^0.14.1", + "sanitize-html": "^2.17.7", "socket.io-client": "^4.8.3", "stream-browserify": "^3.0.0", "tailwind-rn": "^4.2.0", diff --git a/src/screens/mail/EmailDetailScreen/EmailBody.tsx b/src/screens/mail/EmailDetailScreen/EmailBody.tsx new file mode 100644 index 000000000..6759b2438 --- /dev/null +++ b/src/screens/mail/EmailDetailScreen/EmailBody.tsx @@ -0,0 +1,115 @@ +import { EmailResponse } from '@internxt/sdk/dist/mail/types'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Linking, TouchableOpacity, View } from 'react-native'; +import { WebView } from 'react-native-webview'; +import type { ShouldStartLoadRequest, WebViewMessageEvent } from 'react-native-webview/lib/WebViewTypes'; +import { useTailwind } from 'tailwind-rn'; + +import { logger } from '@internxt-mobile/services/common/logger/logger.service'; +import strings from '../../../../assets/lang/strings'; +import AppText from '../../../components/AppText'; +import useGetColor from '../../../hooks/useColor'; +import { + buildEmailBodyHtml, + hasRemoteImages, + type EmailBodySource, +} from '../../../services/mail/emailBody/emailBodyContent'; +import { buildEmailDocument } from '../../../services/mail/emailBody/emailDocument'; +import { useEmailBodyHeight } from './hooks/useEmailBodyHeight'; + +const OPENABLE_SCHEMES = ['http:', 'https:', 'mailto:', 'tel:']; +const GENERIC_USER_AGENT = 'Mozilla/5.0 (Mobile)'; + +export const EmailBody = ({ message, bodySource }: { message: EmailResponse; bodySource: EmailBodySource }) => { + const tailwind = useTailwind(); + const getColor = useGetColor(); + const [areRemoteImagesAllowed, setAreRemoteImagesAllowed] = useState(false); + const isFirstLoad = useRef(true); + const backgroundColor = getColor('bg-surface'); + const textColor = getColor('text-gray-100'); + + const body = useMemo(() => { + const html = buildEmailBodyHtml(message, bodySource); + return { html, hasImagesHostedElsewhere: hasRemoteImages(html) }; + }, [message, bodySource]); + + const emailDocument = useMemo( + () => buildEmailDocument(body.html, { backgroundColor, textColor, areRemoteImagesAllowed }), + [body.html, backgroundColor, textColor, areRemoteImagesAllowed], + ); + + const { height, heightReporterScript, onHeightReported, onMeasureFailed } = useEmailBodyHeight(emailDocument); + + useEffect(() => { + isFirstLoad.current = true; + }, [emailDocument]); + + const onMessageFromBody = (event: WebViewMessageEvent) => { + onHeightReported(event.nativeEvent.data); + }; + + const onNavigationRequested = (request: ShouldStartLoadRequest) => { + if (isFirstLoad.current || request.url === 'about:blank') { + isFirstLoad.current = false; + return true; + } + + const scheme = request.url.slice(0, request.url.indexOf(':') + 1).toLowerCase(); + if (OPENABLE_SCHEMES.includes(scheme)) { + Linking.openURL(request.url).catch((error) => logger.error('Could not open a link from an email:', error)); + } + return false; + }; + + const areImagesBlocked = !areRemoteImagesAllowed && body.hasImagesHostedElsewhere; + + return ( + + {areImagesBlocked && ( + + + {strings.screens.mail.remoteImagesBlocked} + + setAreRemoteImagesAllowed(true)}> + + {strings.screens.mail.showRemoteImages} + + + + )} + + + ); +}; diff --git a/src/screens/mail/EmailDetailScreen/hooks/useEmailBodyHeight.spec.ts b/src/screens/mail/EmailDetailScreen/hooks/useEmailBodyHeight.spec.ts new file mode 100644 index 000000000..e025b1d71 --- /dev/null +++ b/src/screens/mail/EmailDetailScreen/hooks/useEmailBodyHeight.spec.ts @@ -0,0 +1,92 @@ +import { act, renderHook } from '@testing-library/react-hooks'; + +import { useEmailBodyHeight } from './useEmailBodyHeight'; + +jest.mock('@internxt-mobile/services/common/logger/logger.service', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +const A_DOCUMENT = 'Hello there'; +const ANOTHER_DOCUMENT = 'Something else'; + +describe('Measuring how tall a message needs to be', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + test('when the message has not been measured yet, then it has no height', () => { + const { result } = renderHook(() => useEmailBodyHeight(A_DOCUMENT)); + + expect(result.current.height).toBe(0); + }); + + test('when the message reports how tall it is, then that is the height it gets', () => { + const { result } = renderHook(() => useEmailBodyHeight(A_DOCUMENT)); + + act(() => result.current.onHeightReported('640')); + + expect(result.current.height).toBe(640); + }); + + test('when the message claims to be absurdly tall, then the height is capped', () => { + const { result } = renderHook(() => useEmailBodyHeight(A_DOCUMENT)); + + act(() => result.current.onHeightReported('999999')); + + expect(result.current.height).toBeLessThan(999999); + expect(result.current.height).toBeGreaterThan(0); + }); + + test('when what the message reports is not a number, then it is ignored', () => { + const { result } = renderHook(() => useEmailBodyHeight(A_DOCUMENT)); + + act(() => result.current.onHeightReported('640')); + act(() => result.current.onHeightReported('not a height')); + + expect(result.current.height).toBe(640); + }); + + test('when the message reports no height at all, then it is ignored', () => { + const { result } = renderHook(() => useEmailBodyHeight(A_DOCUMENT)); + + act(() => result.current.onHeightReported('640')); + act(() => result.current.onHeightReported('0')); + + expect(result.current.height).toBe(640); + }); + + test('when the message never reports its height, then it still gets one so it is not left invisible', () => { + const { result } = renderHook(() => useEmailBodyHeight(A_DOCUMENT)); + + act(() => jest.runAllTimers()); + + expect(result.current.height).toBeGreaterThan(0); + }); + + test('when the message reported its height in time, then the fallback does not replace it', () => { + const { result } = renderHook(() => useEmailBodyHeight(A_DOCUMENT)); + + act(() => result.current.onHeightReported('640')); + act(() => jest.runAllTimers()); + + expect(result.current.height).toBe(640); + }); + + test('when the message cannot be displayed at all, then it still gets a height', () => { + const { result } = renderHook(() => useEmailBodyHeight(A_DOCUMENT)); + + act(() => result.current.onMeasureFailed(new Error('the page could not be loaded'))); + + expect(result.current.height).toBeGreaterThan(0); + }); + + test('when another message is displayed, then it is measured again instead of keeping the previous height', () => { + const { result, rerender } = renderHook(({ document }) => useEmailBodyHeight(document), { + initialProps: { document: A_DOCUMENT }, + }); + act(() => result.current.onHeightReported('640')); + + rerender({ document: ANOTHER_DOCUMENT }); + + expect(result.current.height).toBe(0); + }); +}); diff --git a/src/screens/mail/EmailDetailScreen/hooks/useEmailBodyHeight.ts b/src/screens/mail/EmailDetailScreen/hooks/useEmailBodyHeight.ts new file mode 100644 index 000000000..cb04c0ced --- /dev/null +++ b/src/screens/mail/EmailDetailScreen/hooks/useEmailBodyHeight.ts @@ -0,0 +1,66 @@ +import { useEffect, useState } from 'react'; + +import { logger } from '@internxt-mobile/services/common/logger/logger.service'; + +const MAX_BODY_HEIGHT = 12000; +const MIN_REPORTED_HEIGHT_CHANGE = 2; +const FALLBACK_BODY_HEIGHT = 320; +const MEASURE_TIMEOUT_MS = 4000; + +const HEIGHT_REPORTER_SCRIPT = ` + (function () { + var lastReported = 0; + var report = function () { + var height = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight); + if (Math.abs(height - lastReported) < ${MIN_REPORTED_HEIGHT_CHANGE}) { + return; + } + lastReported = height; + window.ReactNativeWebView.postMessage(String(height)); + }; + report(); + if (window.ResizeObserver) new ResizeObserver(report).observe(document.body); + })(); + true; +`; + +export type MeasuredEmailBodyHeight = { + height: number; + heightReporterScript: string; + onHeightReported: (reportedHeight: string) => void; + onMeasureFailed: (reason: unknown) => void; +}; + +/** + * Keeps the height a message needs to be displayed in full, which only the document itself knows. + * The height starts unknown and falls back to a height that shows something when the document + * cannot report one, so that a message is never left invisible. + * + * @param emailDocument the document handed to the web view; a new one starts the measuring again + * @returns the height measured so far, the script that reports it, and the handlers for what the + * web view sends back + */ +export const useEmailBodyHeight = (emailDocument: string): MeasuredEmailBodyHeight => { + const [height, setHeight] = useState(0); + + useEffect(() => { + setHeight(0); + const timeout = setTimeout(() => setHeight((current) => current || FALLBACK_BODY_HEIGHT), MEASURE_TIMEOUT_MS); + return () => clearTimeout(timeout); + }, [emailDocument]); + + const onHeightReported = (reportedHeight: string) => { + const measuredHeight = Number(reportedHeight); + if (!Number.isFinite(measuredHeight) || measuredHeight <= 0) { + return; + } + setHeight(Math.min(measuredHeight, MAX_BODY_HEIGHT)); + }; + + const onMeasureFailed = (reason: unknown) => { + logger.error('The body of an email could not be displayed:', reason); + setHeight((current) => current || FALLBACK_BODY_HEIGHT); + }; + + return { height, heightReporterScript: HEIGHT_REPORTER_SCRIPT, onHeightReported, onMeasureFailed }; +}; diff --git a/src/screens/mail/EmailDetailScreen/index.tsx b/src/screens/mail/EmailDetailScreen/index.tsx index 74260cbc2..32417d7d4 100644 --- a/src/screens/mail/EmailDetailScreen/index.tsx +++ b/src/screens/mail/EmailDetailScreen/index.tsx @@ -2,42 +2,42 @@ import { EmailResponse } from '@internxt/sdk/dist/mail/types'; import dayjs from 'dayjs'; import { EnvelopeIcon, TrashIcon, WarningIcon } from 'phosphor-react-native'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { ActivityIndicator, ScrollView, TouchableOpacity, useWindowDimensions, View } from 'react-native'; +import { ActivityIndicator, ScrollView, TouchableOpacity, View } from 'react-native'; import { useTailwind } from 'tailwind-rn'; -import RenderHtml from 'react-native-render-html'; +import { logger } from '@internxt-mobile/services/common/logger/logger.service'; import strings from '../../../../assets/lang/strings'; import AppScreen from '../../../components/AppScreen'; import AppScreenTitle from '../../../components/AppScreenTitle'; import AppText from '../../../components/AppText'; import useGetColor from '../../../hooks/useColor'; import { useLanguage } from '../../../hooks/useLanguage'; +import { type EmailBodySource } from '../../../services/mail/emailBody/emailBodyContent'; import { downloadDecryptAndOpenAttachment } from '../../../services/mail/mailAttachment.service'; -import { logger } from '@internxt-mobile/services/common/logger/logger.service'; import { decryptAndCacheFullEmail, getCachedEmail, getPrivateHybridKey, isEncryptedEmailBody, - parseEncryptionBlock, - markEmailUnread, markEmailRead, + markEmailUnread, moveThreadToMailbox, + parseEncryptionBlock, } from '../../../services/mail/mailCrypto.service'; import { mailboxService } from '../../../services/mail/mailbox.service'; import { useAppSelector } from '../../../store/hooks'; import { MailScreenProps } from '../../../types/navigation'; +import { EmailBody } from './EmailBody'; type ResolvedMessage = { message: EmailResponse; - decryptedBody: string | null; + bodySource: EmailBodySource; attachmentsSessionKey: string | null; }; export function EmailDetailScreen({ route, navigation }: MailScreenProps<'EmailDetail'>): JSX.Element { const tailwind = useTailwind(); const getColor = useGetColor(); - const { width } = useWindowDimensions(); const { user } = useAppSelector((state) => state.auth); useLanguage(); @@ -51,23 +51,29 @@ export function EmailDetailScreen({ route, navigation }: MailScreenProps<'EmailD const resolveMessage = useCallback( async (message: EmailResponse): Promise => { - const cached = await getCachedEmail(message.id); - if (cached) { - return { message, decryptedBody: cached.text, attachmentsSessionKey: cached.attachmentsSessionKey }; + const encryptedEnvelope = message.textBody && isEncryptedEmailBody(message.textBody) ? message.textBody : null; + const buildResolvedMessage = ( + bodySource: EmailBodySource, + attachmentsSessionKey: string | null, + ): ResolvedMessage => ({ message, bodySource, attachmentsSessionKey }); + + const cachedEmail = await getCachedEmail(message.id); + if (cachedEmail) { + return buildResolvedMessage({ type: 'decrypted', text: cachedEmail.text }, cachedEmail.attachmentsSessionKey); } - if (message.textBody && isEncryptedEmailBody(message.textBody) && user?.mnemonic) { + if (encryptedEnvelope && user?.mnemonic) { try { - const encryption = parseEncryptionBlock(message.textBody); + const encryption = parseEncryptionBlock(encryptedEnvelope); const privateKey = await getPrivateHybridKey(user.mnemonic); const decrypted = await decryptAndCacheFullEmail(message.id, encryption, privateKey); - return { message, decryptedBody: decrypted.text, attachmentsSessionKey: decrypted.attachmentsSessionKey }; + return buildResolvedMessage({ type: 'decrypted', text: decrypted.text }, decrypted.attachmentsSessionKey); } catch (error) { logger.error(`Failed to decrypt message ${message.id}`, error); } } - return { message, decryptedBody: null, attachmentsSessionKey: null }; + return buildResolvedMessage(encryptedEnvelope ? { type: 'encryptedUnreadable' } : { type: 'plain' }, null); }, [user], ); @@ -145,12 +151,10 @@ export function EmailDetailScreen({ route, navigation }: MailScreenProps<'EmailD }; const renderMessage = (entry: ResolvedMessage) => { - const { message, decryptedBody, attachmentsSessionKey } = entry; + const { message, bodySource, attachmentsSessionKey } = entry; const isLastMessage = thread[thread.length - 1] === entry; const senderLabel = message.from?.[0]?.name || message.from?.[0]?.email || ''; const recipientsLabel = message.to?.map((recipient) => recipient.name || recipient.email).join(', ') || ''; - const isRawTextEncrypted = !!message.textBody && isEncryptedEmailBody(message.textBody); - const resolvedBody = decryptedBody || (!isRawTextEncrypted ? message.textBody : null) || message.htmlBody || ''; const onPressAttachment = (attachment: NonNullable[number]) => { downloadDecryptAndOpenAttachment({ @@ -187,11 +191,7 @@ export function EmailDetailScreen({ route, navigation }: MailScreenProps<'EmailD - + {message.attachments && message.attachments.length > 0 && ( diff --git a/src/services/mail/emailBody/emailBodyContent.spec.ts b/src/services/mail/emailBody/emailBodyContent.spec.ts new file mode 100644 index 000000000..ed44ca8ed --- /dev/null +++ b/src/services/mail/emailBody/emailBodyContent.spec.ts @@ -0,0 +1,132 @@ +import { EmailResponse } from '@internxt/sdk/dist/mail/types'; + +import { buildEmailBodyHtml, hasRemoteImages, plainTextToHtml, resolveEmailBody } from './emailBodyContent'; + +const anEmail = (fields: Partial): EmailResponse => ({ ...fields }) as EmailResponse; + +describe('Choosing which body of a message to display', () => { + test('when a message carries both a formatted and a plain version, then the formatted one is displayed', () => { + const message = anEmail({ htmlBody: '
An offer
', textBody: 'An offer' }); + + expect(resolveEmailBody(message, { type: 'plain' })).toEqual({ + content: '
An offer
', + isHtml: true, + }); + }); + + test('when a message only carries a plain version, then that one is displayed', () => { + const message = anEmail({ htmlBody: null, textBody: 'Just a few words' }); + + expect(resolveEmailBody(message, { type: 'plain' })).toEqual({ + content: 'Just a few words', + isHtml: false, + }); + }); + + test('when a message was encrypted and could be decrypted, then the decrypted body is displayed', () => { + const message = anEmail({ htmlBody: null, textBody: 'an encrypted payload' }); + + expect(resolveEmailBody(message, { type: 'decrypted', text: 'Hello there' })).toEqual({ + content: 'Hello there', + isHtml: false, + }); + }); + + 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' }); + + expect(resolveEmailBody(message, { type: 'encryptedUnreadable' })).toEqual({ + content: '', + isHtml: false, + }); + }); + + test('when a message has no body at all, then nothing is displayed', () => { + const message = anEmail({ htmlBody: null, textBody: null }); + + expect(resolveEmailBody(message, { type: 'plain' })).toEqual({ + content: '', + isHtml: false, + }); + }); +}); + +describe('Displaying a message written as plain text', () => { + test('when the text spans several lines, then the line breaks are kept', () => { + const html = plainTextToHtml('First line\nSecond line'); + + expect(html).toContain('white-space:pre-wrap'); + expect(html).toContain('First line\nSecond line'); + }); + + test('when the text contains characters that look like markup, then they are shown as written', () => { + const html = plainTextToHtml('a < b && c > d'); + + expect(html).toContain('a < b && c > d'); + }); + + test('when the text has apostrophes and quotation marks, then they are shown without anything added around them', () => { + const html = plainTextToHtml('it\'s the "big" one, isn\'t it?'); + + expect(html).toContain('it's the "big" one, isn't it?'); + }); + + test('when the text looks like a script, then it is shown as text instead of being rendered', () => { + const html = plainTextToHtml(''); + + expect(html).not.toContain('' }); + + const bodyHtml = buildEmailBodyHtml(message, { type: 'plain' }); + + expect(bodyHtml).toContain('
An offer
'); + expect(bodyHtml).not.toContain('stealTheKey'); + }); + + test('when a message carries a link that would run something, then the link is stripped of it', () => { + const message = anEmail({ htmlBody: 'Click here' }); + + expect(buildEmailBodyHtml(message, { type: 'plain' })).not.toContain('javascript'); + }); + + test('when a message is plain text that looks like markup, then it is shown as written instead of being rendered', () => { + const message = anEmail({ htmlBody: null, textBody: '' }); + + const bodyHtml = buildEmailBodyHtml(message, { type: 'plain' }); + + expect(bodyHtml).toContain('<script>'); + expect(bodyHtml).not.toContain(''); + + expect(clean).toBe('

Hello

'); + }); + + test('when a message body contains a link with a javascript scheme, then the link is removed', () => { + const clean = sanitizeMailHtml('Click here'); + + expect(clean).not.toContain('javascript'); + expect(clean).toContain('Click here'); + }); + + test('when a message body contains an inline event handler, then the attribute is stripped', () => { + const clean = sanitizeMailHtml('
Some text
'); + + expect(clean).toBe('
Some text
'); + }); + + test('when a message body references an inline attachment, then the reference survives sanitising', () => { + const clean = sanitizeMailHtml('A signature'); + + expect(clean).toContain('src="cid:the-inline-image"'); + expect(clean).toContain('alt="A signature"'); + }); + + test('when a message body is styled, then the formatting is kept', () => { + const clean = sanitizeMailHtml('
An offer
'); + + expect(clean).toBe('
An offer
'); + }); + + test('when a link is written to the sender, then it is kept', () => { + const clean = sanitizeMailHtml('Reply'); + + expect(clean).toContain('href="mailto:someone@inxt.me"'); + }); + + test('when a link points to a website, then it is kept', () => { + const clean = sanitizeMailHtml('Our website'); + + expect(clean).toContain('href="https://internxt.com"'); + }); + + test('when the scheme of a link is hidden behind an encoded character, then the link is still removed', () => { + const clean = sanitizeMailHtml('Click here'); + + expect(clean).not.toContain('javascript'); + expect(clean).not.toContain('href'); + }); + + test('when a link carries a document of its own, then it is removed', () => { + const clean = sanitizeMailHtml('Open'); + + expect(clean).not.toContain('data:'); + }); + + test('when a link leaves out the scheme, then it is removed instead of guessed', () => { + const clean = sanitizeMailHtml('Click here'); + + expect(clean).not.toContain('somewhere-else.example'); + }); + + test('when a script hides inside a drawing, then nothing of it is rendered', () => { + const clean = sanitizeMailHtml(''); + + expect(clean).not.toContain('stealTheKey'); + }); + + test('when a message body asks for something to be filled in, then the form is removed', () => { + const clean = sanitizeMailHtml('
'); + + expect(clean).toBe(''); + }); + + test('when a message body tries to change where relative links point to, then it is removed', () => { + const clean = sanitizeMailHtml(''); + + expect(clean).toBe(''); + }); + + test('when a message body embeds another page, then it is removed', () => { + const clean = sanitizeMailHtml('

Before

'); + + expect(clean).toBe('

Before

'); + }); + + test('when a message body is empty, then nothing is returned', () => { + expect(sanitizeMailHtml('')).toBe(''); + }); +}); diff --git a/src/services/mail/emailBody/sanitizeMailHtml.ts b/src/services/mail/emailBody/sanitizeMailHtml.ts new file mode 100644 index 000000000..2b6daa1ba --- /dev/null +++ b/src/services/mail/emailBody/sanitizeMailHtml.ts @@ -0,0 +1,21 @@ +import sanitizeHtml from 'sanitize-html'; + +const MAIL_SANITISE_OPTIONS: sanitizeHtml.IOptions = { + allowedTags: [...sanitizeHtml.defaults.allowedTags, 'img'], + allowedAttributes: { + ...sanitizeHtml.defaults.allowedAttributes, + '*': ['style'], + }, + allowedSchemes: ['http', 'https', 'mailto', 'tel', 'cid'], + allowProtocolRelative: false, +}; + +/** + * Filters the body of an incoming email down to the markup that is safe to display: it keeps the + * formatting and the inline attachment references, and drops scripts, event handlers and any link + * whose scheme is not http, https, mailto, tel or cid. + * + * @param html the body of the email as it arrived, before rendering it + * @returns the same body with everything unsafe removed + */ +export const sanitizeMailHtml = (html: string): string => sanitizeHtml(html, MAIL_SANITISE_OPTIONS); diff --git a/yarn.lock b/yarn.lock index 8e3f71275..b9add733d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2134,38 +2134,6 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@jsamr/counter-style@^2.0.1": - version "2.0.2" - resolved "https://registry.yarnpkg.com/@jsamr/counter-style/-/counter-style-2.0.2.tgz#6f08cfa98e1f0416dc1d7f2d8ac38a8cdb004c5d" - integrity sha512-2mXudGVtSzVxWEA7B9jZLKjoXUeUFYDDtFrQoC0IFX9/Dszz4t1vZOmafi3JSw/FxD+udMQ+4TAFR8Qs0J3URQ== - -"@jsamr/react-native-li@^2.3.0": - version "2.3.1" - resolved "https://registry.yarnpkg.com/@jsamr/react-native-li/-/react-native-li-2.3.1.tgz#12a5b5f6e3971cec77b96bee58104eed0ae9314a" - integrity sha512-Qbo4NEj48SQ4k8FZJHFE2fgZDKTWaUGmVxcIQh3msg5JezLdTMMHuRRDYctfdHI6L0FZGObmEv3haWbIvmol8w== - -"@native-html/css-processor@1.11.0": - version "1.11.0" - resolved "https://registry.yarnpkg.com/@native-html/css-processor/-/css-processor-1.11.0.tgz#27d02e5123b0849f4986d44060ba3f235a15f552" - integrity sha512-NnhBEbJX5M2gBGltPKOetiLlKhNf3OHdRafc8//e2ZQxXN8JaSW/Hy8cm94pnIckQxwaMKxrtaNT3x4ZcffoNQ== - dependencies: - css-to-react-native "^3.0.0" - csstype "^3.0.8" - -"@native-html/transient-render-engine@11.2.3": - version "11.2.3" - resolved "https://registry.yarnpkg.com/@native-html/transient-render-engine/-/transient-render-engine-11.2.3.tgz#e4de0e7c8c023224a2dc27f3bd2b30d3984d94a4" - integrity sha512-zXwgA3gPUEmFs3I3syfnvDvS6WiUHXEE6jY09OBzK+trq7wkweOSFWIoyXiGkbXrozGYG0KY90YgPyr8Tg8Uyg== - dependencies: - "@native-html/css-processor" "1.11.0" - "@types/ramda" "^0.27.44" - csstype "^3.0.9" - domelementtype "^2.2.0" - domhandler "^4.2.2" - domutils "^2.8.0" - htmlparser2 "^7.1.2" - ramda "^0.27.2" - "@noble/ciphers@^2.2.0": version "2.4.0" resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-2.4.0.tgz#04cf9e0f1cd3d521e8e5ca92dc0c02238180f676" @@ -2816,13 +2784,6 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7" integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw== -"@types/ramda@^0.27.40", "@types/ramda@^0.27.44": - version "0.27.66" - resolved "https://registry.yarnpkg.com/@types/ramda/-/ramda-0.27.66.tgz#f1a23d13b0087d806a62e3ff941e5e59b3318999" - integrity sha512-i2YW+E2U6NfMt3dp0RxNcejox+bxJUNDjB7BpYuRuoHIzv5juPHkJkNgcUOu+YSQEmaWu8cnAo/8r63C0NnuVA== - dependencies: - ts-toolbelt "^6.15.1" - "@types/react-native-video@^5.0.14": version "5.0.21" resolved "https://registry.yarnpkg.com/@types/react-native-video/-/react-native-video-5.0.21.tgz#3d7be0002907ff44e54a478712cfcd0768ce6edb" @@ -2848,6 +2809,13 @@ "@types/prop-types" "*" csstype "^3.2.2" +"@types/sanitize-html@^2.16.1": + version "2.16.1" + resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-2.16.1.tgz#27b9ac6cc29838f7a048bfec0113e8ad00918d0a" + integrity sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA== + dependencies: + htmlparser2 "^10.1" + "@types/semver@^7.3.12": version "7.7.1" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.7.1.tgz#3ce3af1a5524ef327d2da9e4fd8b6d95c8d70528" @@ -2868,11 +2836,6 @@ resolved "https://registry.yarnpkg.com/@types/unorm/-/unorm-1.3.31.tgz#c93166bb937d5763e374857e1b57847c6c653a4d" integrity sha512-qCPX/Lo14ECb9Wkb/1sxdcTQqIiHTVNlaHczGrh2WqMVSlWjfn8Hu7DxraCtBYz1+Ud6Id/d+4OH/hkd+dlnpw== -"@types/urijs@^1.19.15": - version "1.19.26" - resolved "https://registry.yarnpkg.com/@types/urijs/-/urijs-1.19.26.tgz#500fc9912e0ba01d635480970bdc9ba0f45d7bc6" - integrity sha512-wkXrVzX5yoqLnndOwFsieJA7oKM8cNkOKJtf/3vVGSUFkWDKZvFHpIl9Pvqb/T9UsawBBFMTTD8xu7sK5MWuvg== - "@types/yargs-parser@*": version "21.0.3" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" @@ -3784,16 +3747,6 @@ char-regex@^2.0.0: resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-2.0.2.tgz#81385bb071af4df774bff8721d0ca15ef29ea0bb" integrity sha512-cbGOjAptfM2LVmWhwRFHEKTPkLwNddVmuqYZQt895yXwAsWsXObCG+YN4DGQ/JBtT4GP1a1lPPdio2z413LmTg== -character-entities-html4@^1.0.0: - version "1.1.4" - resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-1.1.4.tgz#0e64b0a3753ddbf1fdc044c5fd01d0199a02e125" - integrity sha512-HRcDxZuZqMx3/a+qrzxdBKBPUpxWEq9xw2OPZ3a/174ihfrQKVsFhqtthBInFy1zZ9GgZyFXOatNujm8M+El3g== - -character-entities-legacy@^1.0.0: - version "1.1.4" - resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz#94bc1845dce70a5bb9d2ecc748725661293d8fc1" - integrity sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA== - child-process-promise@^2.2.0: version "2.2.1" resolved "https://registry.yarnpkg.com/child-process-promise/-/child-process-promise-2.2.1.tgz#4730a11ef610fad450b8f223c79d31d7bdad8074" @@ -4247,7 +4200,7 @@ cssstyle@^2.3.0: dependencies: cssom "~0.3.6" -csstype@^3.0.8, csstype@^3.0.9, csstype@^3.2.2: +csstype@^3.2.2: version "3.2.3" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== @@ -4266,6 +4219,11 @@ dayjs@^1.11.19: resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.19.tgz#15dc98e854bb43917f12021806af897c58ae2938" integrity sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw== +dayjs@^1.11.7: + version "1.11.23" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.23.tgz#b0a363506dde5f36cf5075e42ebe8115165a8c79" + integrity sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ== + debug@2.6.9, debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -4473,15 +4431,6 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" -dom-serializer@^1.0.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30" - integrity sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag== - dependencies: - domelementtype "^2.0.1" - domhandler "^4.2.0" - entities "^2.0.0" - dom-serializer@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" @@ -4491,11 +4440,25 @@ dom-serializer@^2.0.0: domhandler "^5.0.2" entities "^4.2.0" -domelementtype@^2.0.1, domelementtype@^2.2.0, domelementtype@^2.3.0: +dom-serializer@^3.0.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-3.1.1.tgz#54be70ee4fcc2da010f488165e62294798dc57d3" + integrity sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw== + dependencies: + domelementtype "^3.0.0" + domhandler "^6.0.0" + entities "^8.0.0" + +domelementtype@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== +domelementtype@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-3.0.0.tgz#e6c0a24bd39ca5eb7ea67a98d2f699497d7bcc55" + integrity sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg== + domexception@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/domexception/-/domexception-4.0.0.tgz#4ad1be56ccadc86fc76d033353999a8037d03673" @@ -4503,13 +4466,6 @@ domexception@^4.0.0: dependencies: webidl-conversions "^7.0.0" -domhandler@^4.2.0, domhandler@^4.2.2: - version "4.3.1" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c" - integrity sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ== - dependencies: - domelementtype "^2.2.0" - domhandler@^5.0.2, domhandler@^5.0.3: version "5.0.3" resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" @@ -4517,16 +4473,14 @@ domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" -domutils@^2.8.0: - version "2.8.0" - resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135" - integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A== +domhandler@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-6.0.1.tgz#75f351a03a6e10c35e08418f9cf0d287e00797cf" + integrity sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg== dependencies: - dom-serializer "^1.0.1" - domelementtype "^2.2.0" - domhandler "^4.2.0" + domelementtype "^3.0.0" -domutils@^3.0.1: +domutils@^3.0.1, domutils@^3.2.2: version "3.2.2" resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78" integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw== @@ -4535,6 +4489,15 @@ domutils@^3.0.1: domelementtype "^2.3.0" domhandler "^5.0.3" +domutils@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-4.0.2.tgz#0c1ac1fdbe8f554a60a6f5eb143ee85630327c94" + integrity sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA== + dependencies: + dom-serializer "^3.0.0" + domelementtype "^3.0.0" + domhandler "^6.0.0" + dot-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.4.tgz#9b2b670d00a431667a8a75ba29cd1b98809ce751" @@ -4635,16 +4598,6 @@ engine.io-parser@~5.2.1: resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz#00dc5b97b1f233a23c9398d0209504cf5f94d92f" integrity sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q== -entities@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" - integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== - -entities@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/entities/-/entities-3.0.1.tgz#2b887ca62585e96db3903482d336c1006c3001d4" - integrity sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q== - entities@^4.2.0, entities@^4.4.0: version "4.5.0" resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" @@ -4655,6 +4608,16 @@ entities@^6.0.0: resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.1.tgz#c28c34a43379ca7f61d074130b2f5f7020a30694" integrity sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g== +entities@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.1.tgz#26e8a88889db63417dcb9a1e79a3f1bc92b5976b" + integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA== + +entities@^8.0.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-8.1.0.tgz#9632d69619ebdbe0cf0ef22f53088fe5a2163d86" + integrity sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA== + env-editor@^0.4.1: version "0.4.2" resolved "https://registry.yarnpkg.com/env-editor/-/env-editor-0.4.2.tgz#4e76568d0bd8f5c2b6d314a9412c8fe9aa3ae861" @@ -5726,15 +5689,25 @@ html-escaper@^2.0.0: resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== -htmlparser2@^7.1.2: - version "7.2.0" - resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-7.2.0.tgz#8817cdea38bbc324392a90b1990908e81a65f5a5" - integrity sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog== +htmlparser2@^10.1: + version "10.1.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-10.1.0.tgz#fe3f2e12c73b6e462d4e10395db9c1119e4d6ae4" + integrity sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ== dependencies: - domelementtype "^2.0.1" - domhandler "^4.2.2" - domutils "^2.8.0" - entities "^3.0.1" + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.2.2" + entities "^7.0.1" + +htmlparser2@^12.0.0: + version "12.0.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-12.0.0.tgz#6a679d0f57c525990f9cbad8a585b320ecc6d198" + integrity sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw== + dependencies: + domelementtype "^3.0.0" + domhandler "^6.0.0" + domutils "^4.0.2" + entities "^8.0.0" http-errors@~2.0.1: version "2.0.1" @@ -5980,6 +5953,11 @@ is-plain-obj@^2.1.0: resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== +is-plain-object@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.1.0.tgz#22ed7b5e856bb57966348e5b9468dada42be87f4" + integrity sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw== + is-potential-custom-element-name@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" @@ -6654,6 +6632,13 @@ lan-network@^0.2.1: resolved "https://registry.yarnpkg.com/lan-network/-/lan-network-0.2.1.tgz#e4764a0d17f6bd1f2794c838fa219526a1b756f8" integrity sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A== +launder@^1.7.1: + version "1.7.1" + resolved "https://registry.yarnpkg.com/launder/-/launder-1.7.1.tgz#ef7155ab0c3ddec2323089c961d2e9a249aa5b0d" + integrity sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw== + dependencies: + dayjs "^1.11.7" + leven@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" @@ -7643,6 +7628,11 @@ parse-png@^2.1.0: dependencies: pngjs "^3.3.0" +parse-srcset@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1" + integrity sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q== + parse5@^7.0.0, parse5@^7.1.1: version "7.3.0" resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.3.0.tgz#d7e224fa72399c7a175099f45fc2ad024b05ec05" @@ -7852,7 +7842,7 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.0.2, postcss-value-parser@^ resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@^8.4.47, postcss@^8.5.23, postcss@~8.4.32: +postcss@^8.3.11, postcss@^8.4.47, postcss@^8.5.23, postcss@~8.4.32: version "8.5.23" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.23.tgz#3493550116f478487298301d2c2e8dc5a56e6594" integrity sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg== @@ -7942,7 +7932,7 @@ prompts@^2.0.1, prompts@^2.2.1, prompts@^2.3.2: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types@*, prop-types@15.8.1, prop-types@^15.5.7, prop-types@^15.7.2, prop-types@^15.8.1: +prop-types@*, prop-types@15.8.1, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -8041,11 +8031,6 @@ quick-lru@^4.0.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g== -ramda@^0.27.2: - version "0.27.2" - resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.27.2.tgz#84463226f7f36dc33592f6f4ed6374c48306c3f1" - integrity sha512-SbiLPU40JuJniHexQSAgad32hfwd+DRUdwF2PlVuI5RZD0/vahUco7R8vD86J/tcEKKF9vZrUVwgtmGCqlCKyA== - randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -8261,21 +8246,6 @@ react-native-reanimated@~4.1.1: react-native-is-edge-to-edge "^1.2.1" semver "7.7.2" -react-native-render-html@6.3.4: - version "6.3.4" - resolved "https://registry.yarnpkg.com/react-native-render-html/-/react-native-render-html-6.3.4.tgz#01684897bed2de84829e540a1dbb3a7bdf9d0e57" - integrity sha512-H2jSMzZjidE+Wo3qCWPUMU1nm98Vs2SGCvQCz/i6xf0P3Y9uVtG/b0sDbG/cYFir2mSYBYCIlS1Dv0WC1LjYig== - dependencies: - "@jsamr/counter-style" "^2.0.1" - "@jsamr/react-native-li" "^2.3.0" - "@native-html/transient-render-engine" "11.2.3" - "@types/ramda" "^0.27.40" - "@types/urijs" "^1.19.15" - prop-types "^15.5.7" - ramda "^0.27.2" - stringify-entities "^3.1.0" - urijs "^1.19.6" - react-native-safe-area-context@~5.6.0: version "5.6.2" resolved "https://registry.yarnpkg.com/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz#283e006f5b434fb247fcb4be0971ad7473d5c560" @@ -8777,6 +8747,19 @@ sanitize-filename@^1.6.1: dependencies: truncate-utf8-bytes "^1.0.0" +sanitize-html@^2.17.7: + version "2.17.7" + resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.17.7.tgz#b16417c348ea5f99c2451964b6e6621ca6b1da94" + integrity sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg== + dependencies: + deepmerge "^4.2.2" + escape-string-regexp "^4.0.0" + htmlparser2 "^12.0.0" + is-plain-object "^5.0.0" + launder "^1.7.1" + parse-srcset "^1.0.2" + postcss "^8.3.11" + sax@>=0.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/sax/-/sax-1.6.0.tgz#da59637629307b97e7c4cb28e080a7bc38560d5b" @@ -9200,15 +9183,6 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -stringify-entities@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/stringify-entities/-/stringify-entities-3.1.0.tgz#b8d3feac256d9ffcc9fa1fefdcf3ca70576ee903" - integrity sha512-3FP+jGMmMV/ffZs86MoghGqAoqXAdxLrJP4GUdrDN1aIScYih5tuIO3eF4To5AJZ79KDZ8Fpdy7QJnK8SsL1Vg== - dependencies: - character-entities-html4 "^1.0.0" - character-entities-legacy "^1.0.0" - xtend "^4.0.0" - strip-ansi@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" @@ -9597,11 +9571,6 @@ ts-object-utils@0.0.5: resolved "https://registry.yarnpkg.com/ts-object-utils/-/ts-object-utils-0.0.5.tgz#95361cdecd7e52167cfc5e634c76345e90a26077" integrity sha512-iV0GvHqOmilbIKJsfyfJY9/dNHCs969z3so90dQWsO1eMMozvTpnB1MEaUbb3FYtZTGjv5sIy/xmslEz0Rg2TA== -ts-toolbelt@^6.15.1: - version "6.15.5" - resolved "https://registry.yarnpkg.com/ts-toolbelt/-/ts-toolbelt-6.15.5.tgz#cb3b43ed725cb63644782c64fbcad7d8f28c0a83" - integrity sha512-FZIXf1ksVyLcfr7M317jbB67XFJhOO1YqdTcuGaq9q5jLUoTikukZ+98TPjKiP2jC5CgmYdWWYs0s2nLSU0/1A== - tslib@2.8.1, tslib@^2.0.3, tslib@^2.1.0: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" @@ -9773,11 +9742,6 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" -urijs@^1.19.6: - version "1.19.11" - resolved "https://registry.yarnpkg.com/urijs/-/urijs-1.19.11.tgz#204b0d6b605ae80bea54bea39280cdb7c9f923cc" - integrity sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ== - url-parse@^1.5.3: version "1.5.10" resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" @@ -10097,11 +10061,6 @@ xmlhttprequest-ssl@~2.1.1: resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz#e9e8023b3f29ef34b97a859f584c5e6c61418e23" integrity sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ== -xtend@^4.0.0: - version "4.0.2" - resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" - integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== - y18n@^5.0.5: version "5.0.8" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" From a355ba324aa82b6ad471577837d29e81ece49681 Mon Sep 17 00:00:00 2001 From: Ramon Candel Date: Wed, 9 Sep 2026 17:02:17 +0200 Subject: [PATCH 2/3] Bump Node to 22 in the unit test workflow --- .github/workflows/unit-tests.yml | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 70532791a..abb4ef40a 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -9,7 +9,7 @@ jobs: strategy: matrix: - node-version: [20] + node-version: [22] fail-fast: true steps: diff --git a/package.json b/package.json index c8d08850f..9cd669184 100644 --- a/package.json +++ b/package.json @@ -180,7 +180,7 @@ "typescript": "~5.9.2" }, "engines": { - "node": ">=20" + "node": ">=22.12.0" }, "resolutions": { "pbkdf2": "^3.1.3", From 697986ef529d753867d23491cb148bcb8370a704 Mon Sep 17 00:00:00 2001 From: Ramon Candel Date: Wed, 9 Sep 2026 17:10:46 +0200 Subject: [PATCH 3/3] Fixed sonar issues --- src/screens/mail/EmailDetailScreen/EmailBody.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/screens/mail/EmailDetailScreen/EmailBody.tsx b/src/screens/mail/EmailDetailScreen/EmailBody.tsx index 6759b2438..996789f25 100644 --- a/src/screens/mail/EmailDetailScreen/EmailBody.tsx +++ b/src/screens/mail/EmailDetailScreen/EmailBody.tsx @@ -17,7 +17,7 @@ import { import { buildEmailDocument } from '../../../services/mail/emailBody/emailDocument'; import { useEmailBodyHeight } from './hooks/useEmailBodyHeight'; -const OPENABLE_SCHEMES = ['http:', 'https:', 'mailto:', 'tel:']; +const OPENABLE_SCHEMES = new Set(['http:', 'https:', 'mailto:', 'tel:']); const GENERIC_USER_AGENT = 'Mozilla/5.0 (Mobile)'; export const EmailBody = ({ message, bodySource }: { message: EmailResponse; bodySource: EmailBodySource }) => { @@ -55,7 +55,7 @@ export const EmailBody = ({ message, bodySource }: { message: EmailResponse; bod } const scheme = request.url.slice(0, request.url.indexOf(':') + 1).toLowerCase(); - if (OPENABLE_SCHEMES.includes(scheme)) { + if (OPENABLE_SCHEMES.has(scheme)) { Linking.openURL(request.url).catch((error) => logger.error('Could not open a link from an email:', error)); } return false;