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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We generate CHANGELOG at release time according to commit list no need to fulfill it manually.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/frontend/public/locales/common/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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</1>.",
"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.",
Expand Down Expand Up @@ -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 <strong>{{mailboxAddress}}</strong> has been created successfully.": "The personal mailbox <1>{{mailboxAddress}}</1> has been created successfully.",
Expand All @@ -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",
Expand Down Expand Up @@ -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."
}
}
8 changes: 7 additions & 1 deletion src/frontend/public/locales/common/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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</1>",
"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.",
Expand Down Expand Up @@ -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 <strong>{{mailboxAddress}}</strong> has been created successfully.": "L'adresse personnelle <strong>{{mailboxAddress}}</strong> a été créée avec succès.",
Expand All @@ -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",
Expand Down Expand Up @@ -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.",
Expand All @@ -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."
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cunningham already expose modals to ask confirmation.
Take a look at https://suitenumerique.github.io/cunningham/storybook/?path=/story/components-modal--confirmation-modal

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just add an attachment preview feature on message. In the viewer, when PDF files have links, a confirmation modal display the link and ask user confirmation to navigate. IMO we should use the same wording and layout :
image

Original file line number Diff line number Diff line change
@@ -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.
* <a href={url}>{linkText}</a>
*
* @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 (
<Modal
isOpen={isOpen}
size={ModalSize.SMALL}
title={(
<span className="c__modal__text--centered">{
hardWarning
? i18n.t('Be careful!')
: i18n.t('This links redirects to :')
}</span>
)}
titleIcon={hardWarning && (
<span
className="material-icons modal-message-error-icon"
>
{iconFromType(VariantType.WARNING)}
</span>
Comment on lines +42 to +46
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an Icon component. Furthermore if you need to tweak the size instead to do that in scss file, you can use the size prop.

Suggested change
<span
className="material-icons modal-message-error-icon"
>
{iconFromType(VariantType.WARNING)}
</span>
<Icon name={{iconFromType(VariantType.WARNING)}} />

)}
hideCloseButton={true}
actions={[
<Button
key="cancel"
variant={hardWarning ? "primary" : "tertiary"}
onClick={() => decision(false)}
>
{t("Cancel")}
</Button>,
<Button
key="confirm"
variant={hardWarning ? "tertiary" : "primary"}
color={hardWarning ? "error" : "neutral"}
onClick={() => decision(true)}
>
{t("Open the link")}
</Button>
]}
onClose={() => decision(false)}
closeOnClickOutside={true}
>
<div className="link-preview__children">
{hardWarning && i18n.t('The link you clicked is probably unsafe :')}
<Alert type={hardWarning ? VariantType.WARNING : VariantType.NEUTRAL}>{url}</Alert>
<p className="link-preview__phishing-notice">
<Trans i18nKey="phishing_notice">
Be careful when clicking links in email, it could be a
<a href={`https://www.service-public.gouv.fr/particuliers/vosdroits/F34800`}>
phishing attempt
</a>.
</Trans>
</p>
</div>
</Modal >
)
}

/**
* 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<boolean>((resolve) => {
resolverRef.current = resolve;
});
}, []);

const decision = useCallback((choice: boolean) => {
setIsOpen(false);
if (resolverRef.current) {
resolverRef.current(choice);
resolverRef.current = null;
}
}, []);

const modal = isOpen ? (
<LinkPreviewModal
isOpen={isOpen}
url={url}
hardWarning={hardWarning}
decision={decision}
/>
) : null;

return { askConfirmation, modal };
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO if the message is a spam, link should be simply disabled.

Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -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<HTMLIFrameElement>(null);
const { cunninghamTheme, variant } = useTheme();
const { selectedMailbox } = useMailboxContext();
Expand All @@ -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<string, string>();
Expand Down Expand Up @@ -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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feel this is a little bit fragile.

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', 'rel=noopener');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
window.open(url, '_blank', 'rel=noopener');
window.open(url, '_blank', 'noopener,noreferrer');

}
}

useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data === 'iframe-loaded') {
Expand Down Expand Up @@ -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}
</>
)
}
Expand Down