From da56be94a3a43baebad1cf104c0f3c77f773fc00 Mon Sep 17 00:00:00 2001 From: Benno Tielen Date: Wed, 3 Jun 2026 11:44:01 +0200 Subject: [PATCH] feature: obfuscate email addresses for improved security --- .../ContactPerson2/ContactPerson2.tsx | 8 ++- .../ContactPerson2/styles.module.scss | 2 +- .../ContactPersonCard/ContactPersonCard.tsx | 26 +++++--- .../ContactPersonCard/styles.module.scss | 24 ++++++-- .../SecureEmail/SecureEmail.stories.tsx | 23 ++++++++ src/components/SecureEmail/SecureEmail.tsx | 59 +++++++++++++++++++ src/utils/dto/email.ts | 18 ++++++ 7 files changed, 145 insertions(+), 15 deletions(-) create mode 100644 src/components/SecureEmail/SecureEmail.stories.tsx create mode 100644 src/components/SecureEmail/SecureEmail.tsx create mode 100644 src/utils/dto/email.ts diff --git a/src/components/ContactPerson2/ContactPerson2.tsx b/src/components/ContactPerson2/ContactPerson2.tsx index bec70bc..8f20fb3 100644 --- a/src/components/ContactPerson2/ContactPerson2.tsx +++ b/src/components/ContactPerson2/ContactPerson2.tsx @@ -1,6 +1,8 @@ import styles from "./styles.module.scss" import { ContactPerson } from '@/payload-types' import Image, { StaticImageData } from 'next/image' +import { SecureEmail } from '@/components/SecureEmail/SecureEmail' +import { extractEmailPartsEncoded } from '@/utils/dto/email' type ContactPerson2Props = { contact: null | string | undefined | ContactPerson @@ -12,6 +14,8 @@ export const ContactPerson2 = ({contact, photo}: ContactPerson2Props) => { return "Unbekannt" } + const { user, domain } = contact.email ? extractEmailPartsEncoded(contact.email) : { user: '', domain: '' } + return (
{ photo && @@ -24,9 +28,9 @@ export const ContactPerson2 = ({contact, photo}: ContactPerson2Props) => { }
{contact.name} - {contact.email && + {user && domain && <> -
{contact.email} +
✉️ } { diff --git a/src/components/ContactPerson2/styles.module.scss b/src/components/ContactPerson2/styles.module.scss index 2e1f025..ec84d61 100644 --- a/src/components/ContactPerson2/styles.module.scss +++ b/src/components/ContactPerson2/styles.module.scss @@ -17,7 +17,7 @@ color: inherit; } -.hover:hover { +.contact a:hover { text-decoration: underline; } diff --git a/src/components/ContactPersonCard/ContactPersonCard.tsx b/src/components/ContactPersonCard/ContactPersonCard.tsx index f8720ac..80bb946 100644 --- a/src/components/ContactPersonCard/ContactPersonCard.tsx +++ b/src/components/ContactPersonCard/ContactPersonCard.tsx @@ -1,6 +1,8 @@ import styles from './styles.module.scss' import { ContactPerson } from '@/payload-types' import Image, { StaticImageData } from 'next/image' +import { SecureEmail } from '@/components/SecureEmail/SecureEmail' +import { extractEmailPartsEncoded } from '@/utils/dto/email' type ContactPersonCardProps = { contact: null | string | undefined | ContactPerson @@ -19,6 +21,13 @@ export const ContactPersonCard = ({ return null } + let user, domain = null; + if (contact.email) { + let data = extractEmailPartsEncoded(contact.email) + user = data.user; + domain = data.domain; + } + return (
@@ -38,19 +47,20 @@ export const ContactPersonCard = ({ {contact.role && (

{contact.role}

)} -
- {contact.name} - {contact.email && ( - - {contact.email} - +
{contact.name}
+
+ {user && domain && ( + ✉️ )} + {contact.telephone && ( - + + 📞 {contact.telephone} + )} -
+
diff --git a/src/components/ContactPersonCard/styles.module.scss b/src/components/ContactPersonCard/styles.module.scss index a94aaed..c0893c0 100644 --- a/src/components/ContactPersonCard/styles.module.scss +++ b/src/components/ContactPersonCard/styles.module.scss @@ -20,21 +20,37 @@ margin: 0; } -.info { +.phoneEmail { display: flex; - flex-direction: column; - gap: 4px; + gap: 15px; } .name { font-weight: bold; } -.link { +.phoneEmail a { text-decoration: none; color: inherit; &:hover { text-decoration: underline; } +} + +@media screen and (max-width: 576px) { + .role { + text-align: center; + } + + .card { + flex-direction: column; + align-items: center; + gap: 10px; + } + + .phoneEmail { + flex-direction: column; + gap: 0 + } } \ No newline at end of file diff --git a/src/components/SecureEmail/SecureEmail.stories.tsx b/src/components/SecureEmail/SecureEmail.stories.tsx new file mode 100644 index 0000000..052d907 --- /dev/null +++ b/src/components/SecureEmail/SecureEmail.stories.tsx @@ -0,0 +1,23 @@ +import { Meta, StoryObj } from '@storybook/react' +import { SecureEmail } from './SecureEmail' + +const meta: Meta = { + component: SecureEmail, +} + +type Story = StoryObj; +export default meta + +export const Default: Story = { + args: { + user: btoa('pastoor'), + domain: btoa('parochie.nl'), + }, +} + +export const WithLabel: Story = { + args: { + ...Default.args, + label: 'Send email' + } +} \ No newline at end of file diff --git a/src/components/SecureEmail/SecureEmail.tsx b/src/components/SecureEmail/SecureEmail.tsx new file mode 100644 index 0000000..14b59ff --- /dev/null +++ b/src/components/SecureEmail/SecureEmail.tsx @@ -0,0 +1,59 @@ +'use client'; +import React, { useRef } from 'react'; + +interface SecureEmailProps { + /** + * base64-encoded email, e.g. btoa('pastoor') + */ + user: string; + + /** + * base64-encoded domain, e.g. btoa('parochie.nl') + */ + domain: string; + label?: string; +} + +/** + * Generates a secure email link that requires user interaction to reveal the actual "mailto:" address, + * preventing the address from being easily scraped by bots. + * + * @param {Object} props - The properties for the SecureEmail component. + * @param {string} props.user - The Base64-encoded user portion of the email address. + * @param {string} props.domain - The Base64-encoded domain portion of the email address. + * @param {string} [props.label='E-Mail schreiben'] - The label text displayed for the email link. + * @return {JSX.Element} A React component that renders an email link. + */ +export function SecureEmail({ user, domain, label = 'E-Mail schreiben' }: SecureEmailProps) { + const ref = useRef(null); + + // Decode and write the real mailto: into the href, imperatively (not via state), + // so it's in place synchronously before the browser navigates. + const reveal = () => { + const el = ref.current; + if (!el || el.href.startsWith('mailto:')) return; + el.href = `mailto:${atob(user)}@${atob(domain)}`; + }; + + // Fallback: if a click somehow lands before reveal fired (some touch flows), + // assemble the address then and navigate manually. + const handleClick = (e: React.MouseEvent) => { + if (!ref.current?.href.startsWith('mailto:')) { + e.preventDefault(); + reveal(); + window.location.href = ref.current!.href; + } + }; + + return ( + + {label} + +); +} \ No newline at end of file diff --git a/src/utils/dto/email.ts b/src/utils/dto/email.ts new file mode 100644 index 0000000..340d290 --- /dev/null +++ b/src/utils/dto/email.ts @@ -0,0 +1,18 @@ +/** + * Extracts and encodes the user and domain parts of an email address. + * + * This function splits the given email string into two parts: the user (local-part) and the domain. + * Each part is then Base64 encoded and returned as an object. + * + * Used for obfuscating email addresses to prevent direct exposure. + * + * @param {string} email - The email address to be processed. + * @returns {{ user: string, domain: string }} An object containing the Base64 encoded user and domain parts of the email. + */ +export const extractEmailPartsEncoded = (email: string): { user: string, domain: string } => { + const [user, domain] = email.split('@'); + return { + user: btoa(user), + domain: btoa(domain) + }; +} \ No newline at end of file