From 326d1ff0c57a9d4cdf76451473662c522989a4e4 Mon Sep 17 00:00:00 2001 From: Valentin Regnault Date: Thu, 16 Apr 2026 08:41:32 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8(frontend)=20Add=20a=20link=20prev?= =?UTF-8?q?iew=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of opening the link directly when clicked on in an email, a modal now shows the true URL and asks to confirm. Signed-off-by: Valentin Regnault --- CHANGELOG.md | 2 + src/frontend/public/locales/common/en-US.json | 7 +- src/frontend/public/locales/common/fr-FR.json | 8 +- .../components/thread-view/_index.scss | 16 +++ .../thread-message/link-preview-modal.tsx | 127 ++++++++++++++++++ .../thread-message/thread-message-body.tsx | 30 ++++- 6 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 src/frontend/src/features/layouts/components/thread-view/components/thread-message/link-preview-modal.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 163d03d31..c1fc8f68e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ and this project adheres to - Add unread and starred filters in thread panel #581 - Add better filtering and granularity for usage metrics - Expose `oidc_autojoin` and `identity_sync` flags in provisioning API +- Add a link preview modal : instead of opening the link directly when clicked on in an email, a modal now shows the true URL and asks to confirm. + ### Changed diff --git a/src/frontend/public/locales/common/en-US.json b/src/frontend/public/locales/common/en-US.json index 3c36d6480..21be10743 100755 --- a/src/frontend/public/locales/common/en-US.json +++ b/src/frontend/public/locales/common/en-US.json @@ -150,6 +150,7 @@ "Back": "Back", "Back to your inbox": "Back to your inbox", "BCC: ": "BCC: ", + "Be careful!": "Be careful!", "Blind copy: ": "Blind copy: ", "Calendar invite": "Calendar invite", "Cancel": "Cancel", @@ -432,6 +433,7 @@ "On going": "On going", "Open {{driveAppName}} preview": "Open {{driveAppName}} preview", "Open filters": "Open filters", + "Open the link": "Open the link", "Open the menu": "Open the menu", "Or": "Or", "or drag and drop some files": "or drag and drop some files", @@ -442,6 +444,7 @@ "Password reset successfully!": "Password reset successfully!", "Personal mailbox": "Personal mailbox", "Personal mailboxes cannot be created when identity synchronization is disabled.": "Personal mailboxes cannot be created when identity synchronization is disabled.", + "phishing_notice": "Be careful when clicking links in email, it could be a <1> phishing attempt.", "Please enter a valid email address.": "Please enter a valid email address.", "Prefix can only contain letters, numbers, dots, underscores and hyphens.": "Prefix can only contain letters, numbers, dots, underscores and hyphens.", "Prefix is required.": "Prefix is required.", @@ -553,6 +556,7 @@ "The email {{email}} is invalid.": "The email {{email}} is invalid.", "The email address is invalid.": "The email address is invalid.", "The forced signature will be the only one usable for new messages.": "The forced signature will be the only one usable for new messages.", + "The link you clicked is probably unsafe :": "The link you clicked is probably unsafe :", "The message could not be sent.": "The message could not be sent.", "The message could not be sent. Please try again later.": "The message could not be sent. Please try again later.", "The personal mailbox {{mailboxAddress}} has been created successfully.": "The personal mailbox <1>{{mailboxAddress}} has been created successfully.", @@ -567,6 +571,7 @@ "This email prefix is not allowed for personal mailboxes. Please choose a different prefix.": "This email prefix is not allowed for personal mailboxes. Please choose a different prefix.", "This event has been cancelled": "This event has been cancelled", "This is the only admin of this mailbox, you cannot therefore modify its access.": "This is the only admin of this mailbox, you cannot therefore modify its access.", + "This links redirects to :": "This links redirects to :", "This message has {{count}} attachments_one": "This message has one attachment", "This message has {{count}} attachments_other": "This message has {{count}} attachments", "This message has a draft": "This message has a draft", @@ -645,4 +650,4 @@ "Your email...": "Your email...", "Your messages have been imported successfully!": "Your messages have been imported successfully!", "Your session has expired. Please log in again.": "Your session has expired. Please log in again." -} +} \ No newline at end of file diff --git a/src/frontend/public/locales/common/fr-FR.json b/src/frontend/public/locales/common/fr-FR.json index e28827ae0..7719738eb 100755 --- a/src/frontend/public/locales/common/fr-FR.json +++ b/src/frontend/public/locales/common/fr-FR.json @@ -192,6 +192,7 @@ "Back": "Précédent", "Back to your inbox": "Retour à votre messagerie", "BCC: ": "CCI : ", + "Be careful!": "Attention !", "Blind copy: ": "Copie cachée : ", "Calendar invite": "Invitation calendrier", "Cancel": "Annuler", @@ -479,6 +480,7 @@ "On going": "En cours", "Open {{driveAppName}} preview": "Ouvrir l'aperçu dans {{driveAppName}}", "Open filters": "Ouvrir les filtres", + "Open the link": "Ouvrir le lien", "Open the menu": "Ouvrir le menu", "Or": "Ou", "or drag and drop some files": "ou glissez-déposez des fichiers", @@ -489,6 +491,7 @@ "Password reset successfully!": "Mot de passe réinitialisé avec succès !", "Personal mailbox": "Boîte personnelle", "Personal mailboxes cannot be created when identity synchronization is disabled.": "Les boîtes aux lettres personnelles ne peuvent pas être créées lorsque la synchronisation d'identité est désactivée.", + "phishing_notice": "Soyez prudent en cliquant sur les liens dans les emails, il pourrait s'agir d'une <1> tentative de phishing", "Please enter a valid email address.": "Veuillez saisir une adresse email valide.", "Prefix can only contain letters, numbers, dots, underscores and hyphens.": "Le préfixe ne peut contenir que des lettres, chiffres, points, tirets bas et tirets.", "Prefix is required.": "Le préfixe est requis.", @@ -603,6 +606,7 @@ "The email {{email}} is invalid.": "Le courriel {{email}} est invalide.", "The email address is invalid.": "L'adresse email est invalide.", "The forced signature will be the only one usable for new messages.": "La signature forcée sera la seule utilisable pour les nouveaux messages.", + "The link you clicked is probably unsafe :": "Le lien sur lequel vous avez cliqué est probablement dangereux :", "The message could not be sent.": "Le message n'a pas pu être envoyé.", "The message could not be sent. Please try again later.": "Le message n'a pas pu être envoyé. Veuillez réessayer plus tard.", "The personal mailbox {{mailboxAddress}} has been created successfully.": "L'adresse personnelle {{mailboxAddress}} a été créée avec succès.", @@ -617,6 +621,7 @@ "This email prefix is not allowed for personal mailboxes. Please choose a different prefix.": "Ce préfixe d'adresse n'est pas autorisé pour les boîtes aux lettres personnelles. Veuillez choisir un autre préfixe.", "This event has been cancelled": "Cet événement a été annulé", "This is the only admin of this mailbox, you cannot therefore modify its access.": "C'est le seul administrateur de cette boîte aux lettres, vous ne pouvez donc pas modifier son accès.", + "This links redirects to :": "Ce lien redirige vers :", "This message has {{count}} attachments_one": "Ce message a une pièce jointe", "This message has {{count}} attachments_many": "Ce message a {{count}} pièces jointes", "This message has {{count}} attachments_other": "Ce message a {{count}} pièces jointes", @@ -688,6 +693,7 @@ "You can now inform the person that their mailbox is ready to be used and communicate the instructions for authentication.": "Vous pouvez désormais prévenir la personne que sa boîte aux lettres est prête à être utilisée et lui communiquer les instructions pour s'authentifier.", "You cannot delete the last editor of this thread": "Vous ne pouvez pas supprimer le dernier éditeur de cette conversation", "You cannot modify it.": "Vous ne pouvez pas la modifier.", + "You clicked on the link \"{{linkText}}\" which redirects to :": "Vous avez cliqué sur le lien \"{{linkText}}\" qui redirige vers :", "You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients._one": "Vous avez {{count}} destinataire, ce qui dépasse le maximum de {{max}} destinataires autorisés par message. Le message ne peut pas être envoyé tant que vous n'avez pas réduit le nombre de destinataires.", "You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients._many": "Vous avez {{count}} destinataires, ce qui dépasse le maximum de {{max}} destinataires autorisés par message. Le message ne peut pas être envoyé tant que vous n'avez pas réduit le nombre de destinataires.", "You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients._other": "Vous avez {{count}} destinataires, ce qui dépasse le maximum de {{max}} destinataires autorisés par message. Le message ne peut pas être envoyé tant que vous n'avez pas réduit le nombre de destinataires.", @@ -697,4 +703,4 @@ "Your email...": "Renseigner votre email...", "Your messages have been imported successfully!": "Vos messages ont été importés avec succès !", "Your session has expired. Please log in again.": "Votre session a expiré. Veuillez vous reconnecter." -} +} \ No newline at end of file diff --git a/src/frontend/src/features/layouts/components/thread-view/_index.scss b/src/frontend/src/features/layouts/components/thread-view/_index.scss index b08728182..0a5015817 100644 --- a/src/frontend/src/features/layouts/components/thread-view/_index.scss +++ b/src/frontend/src/features/layouts/components/thread-view/_index.scss @@ -146,3 +146,19 @@ top: 0; z-index: 2; } + +.c__modal__title-icon .material-icons { + font-size: var(--icon-size); +} + +.link-preview__children { + display: flex; + flex-direction: column; + gap: var(--c--globals--spacings--xs); +} + +.link-preview__phishing-notice { + font-size: var(--c--globals--font--sizes--xs); + color: var(--c--contextuals--content--semantic--neutral--secondary); + text-align: left; +} \ No newline at end of file diff --git a/src/frontend/src/features/layouts/components/thread-view/components/thread-message/link-preview-modal.tsx b/src/frontend/src/features/layouts/components/thread-view/components/thread-message/link-preview-modal.tsx new file mode 100644 index 000000000..f84f23c13 --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-view/components/thread-message/link-preview-modal.tsx @@ -0,0 +1,127 @@ +import { useState, useCallback, useRef } from "react"; +import i18n from "@/features/i18n/initI18n"; +import { Button, Modal, ModalSize, Alert, VariantType, iconFromType } from "@gouvfr-lasuite/cunningham-react"; +import classNames from "classnames"; +import { Trans, useTranslation } from "react-i18next"; +/** + * Modal component to show a preview of a link. + * {linkText} + * + * @param isOpen - Whether the modal is open + * @param onClose - Function to call when the modal is closed + * @param url - The URL to preview + * @param linkText - The text of the link (optional) + * @param hardWarning - Whether to show a more prominent warning + * @param decision - Function to call with the user's confirmation choice + */ +type LinkPreviewModalProps = { + isOpen: boolean; + url: string; + hardWarning?: boolean; + decision: (choice: boolean) => void; +} + +/** + * Confirmation modal before redirecting to an external link. + * It alerts the user about potential risks (phishing, etc.). + */ +export const LinkPreviewModal = ({ isOpen, url, hardWarning, decision }: LinkPreviewModalProps) => { + const { t } = useTranslation(); + return ( + { + hardWarning + ? i18n.t('Be careful!') + : i18n.t('This links redirects to :') + } + )} + titleIcon={hardWarning && ( + + {iconFromType(VariantType.WARNING)} + + )} + hideCloseButton={true} + actions={[ + , + + ]} + onClose={() => decision(false)} + closeOnClickOutside={true} + > +
+ {hardWarning && i18n.t('The link you clicked is probably unsafe :')} + {url} +

+ + Be careful when clicking links in email, it could be a + + phishing attempt + . + +

+
+
+ ) +} + +/** + * Hook to manage the state and logic of the link preview modal. + * Exposes an asynchronous `askConfirmation` function that waits for user action. + * + * @returns An object containing: + * - `askConfirmation`: an async function that opens the modal and returns a boolean (`true` if confirmed) + * - `modal`: the React node of the modal to be injected into the component tree + */ +export const useLinkPreviewModal = () => { + const [isOpen, setIsOpen] = useState(false); + const [url, setUrl] = useState(''); + const [hardWarning, setHardWarning] = useState(false); + const resolverRef = useRef<((choice: boolean) => void) | null>(null); + + const askConfirmation = useCallback((urlToPreview: string, isHardWarning: boolean = false, textToPreview?: string) => { + setUrl(urlToPreview); + setHardWarning(isHardWarning); + setIsOpen(true); + + return new Promise((resolve) => { + resolverRef.current = resolve; + }); + }, []); + + const decision = useCallback((choice: boolean) => { + setIsOpen(false); + if (resolverRef.current) { + resolverRef.current(choice); + resolverRef.current = null; + } + }, []); + + const modal = isOpen ? ( + + ) : null; + + return { askConfirmation, modal }; +} \ No newline at end of file diff --git a/src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-body.tsx b/src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-body.tsx index 6f56c6c61..db3b1d81f 100644 --- a/src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-body.tsx +++ b/src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-body.tsx @@ -3,7 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { getRequestUrl, getApiOrigin } from "@/features/api/utils"; import { getBlobDownloadRetrieveUrl } from "@/features/api/gen/blob/blob"; import { UnquoteMessage } from '@/features/utils/unquote-message'; -import { useTranslation } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import { tokens } from '@/styles/cunningham-tokens' import { useTheme } from "@/features/providers/theme"; import { useConfig } from "@/features/providers/config"; @@ -14,6 +14,11 @@ import { getMailboxesImageProxyListUrl } from "@/features/api/gen/mailboxes/mail import { EXTERNAL_IMAGES_CONSENT_KEY } from "@/features/config/constants"; import { renderBodyParts } from "./renderers"; import { ThreadMessageBodyProps } from "./types"; +import { LinkPreviewModal, useLinkPreviewModal } from "./link-preview-modal"; +import { Alert, Button, iconFromType, VariantType } from "@gouvfr-lasuite/cunningham-react"; +import { handle } from "@/features/utils/errors"; +import { set } from "zod"; +import Link from "next/link"; const CSP = [ // Allow images from our domain, data URIs, and API endpoints @@ -44,7 +49,7 @@ const CSP = [ ].join('; '); const ThreadMessageBody = ({ bodyParts, attachments = [], isHidden = false, messageId, onLoad }: ThreadMessageBodyProps) => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const iframeRef = useRef(null); const { cunninghamTheme, variant } = useTheme(); const { selectedMailbox } = useMailboxContext(); @@ -64,6 +69,8 @@ const ThreadMessageBody = ({ bodyParts, attachments = [], isHidden = false, mess setDisplayExternalImages(true); }; + const { askConfirmation: askLinkConfirmation, modal: linkConfirmationModal } = useLinkPreviewModal(); + // Build CID to blob URL mapping for inline image resolution const cidToBlobUrlMap = useMemo(() => { const map = new Map(); @@ -321,10 +328,28 @@ const ThreadMessageBody = ({ bodyParts, attachments = [], isHidden = false, mess doc.querySelectorAll('details.email-quoted-content').forEach(node => { node.addEventListener('toggle', resizeIframe); }); + + // Handle link clicks + doc.querySelectorAll('a').forEach(link => { + link.addEventListener('click', (e) => { + if (link.href != link.textContent) { + e.preventDefault(); + handleLinkClick(link.href, link.textContent); + } + }); + }); } onLoad?.(); }, [onLoad, resizeIframe]); + const handleLinkClick = async (url: string, text: string) => { + const lang = i18n.language.split('-')[0]; // Get base language for documentation link + const decision = await askLinkConfirmation(url, false, text); + if (decision) { + window.open(url, '_blank'); + } + } + useEffect(() => { const handleMessage = (event: MessageEvent) => { if (event.data === 'iframe-loaded') { @@ -380,6 +405,7 @@ const ThreadMessageBody = ({ bodyParts, attachments = [], isHidden = false, mess sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation" onLoad={handleIframeLoad} /> + {linkConfirmationModal} ) } From 4182f59081da4b8002fce2ed029da52478a5a492 Mon Sep 17 00:00:00 2001 From: Valentin Regnault Date: Fri, 1 May 2026 11:33:41 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=8F=EF=B8=8F(changelog)=20Moved=20lin?= =?UTF-8?q?k=20preview=20modal=20change=20to=20the=20[Unreleased]=20sectio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Valentin Regnault --- CHANGELOG.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1fc8f68e..90e0882f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to ## [Unreleased] +### Added + +- Add a link preview modal : instead of opening the link directly when clicked on in an email, a modal now shows the true URL and asks to confirm. + ## [0.5.0] - 2026-03-16 ### Added @@ -18,8 +22,6 @@ and this project adheres to - Add unread and starred filters in thread panel #581 - Add better filtering and granularity for usage metrics - Expose `oidc_autojoin` and `identity_sync` flags in provisioning API -- Add a link preview modal : instead of opening the link directly when clicked on in an email, a modal now shows the true URL and asks to confirm. - ### Changed From 36002158faf4d2546838db13e30c6973e0d09243 Mon Sep 17 00:00:00 2001 From: Valentin Regnault Date: Fri, 1 May 2026 11:51:49 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F(frontend)=20Security?= =?UTF-8?q?=20fix=20:=20open=20links=20with=20rel=3Dnoopener=20to=20avoid?= =?UTF-8?q?=20tabnabbing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Valentin Regnault --- .../components/thread-message/thread-message-body.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-body.tsx b/src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-body.tsx index db3b1d81f..246c9bd9d 100644 --- a/src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-body.tsx +++ b/src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-body.tsx @@ -346,7 +346,7 @@ const ThreadMessageBody = ({ bodyParts, attachments = [], isHidden = false, mess const lang = i18n.language.split('-')[0]; // Get base language for documentation link const decision = await askLinkConfirmation(url, false, text); if (decision) { - window.open(url, '_blank'); + window.open(url, '_blank', 'rel=noopener'); } }