feature: obfuscate email addresses for improved security

This commit is contained in:
Benno Tielen 2026-06-03 11:44:01 +02:00
parent f175afaf19
commit da56be94a3
7 changed files with 145 additions and 15 deletions

View file

@ -1,6 +1,8 @@
import styles from "./styles.module.scss" import styles from "./styles.module.scss"
import { ContactPerson } from '@/payload-types' import { ContactPerson } from '@/payload-types'
import Image, { StaticImageData } from 'next/image' import Image, { StaticImageData } from 'next/image'
import { SecureEmail } from '@/components/SecureEmail/SecureEmail'
import { extractEmailPartsEncoded } from '@/utils/dto/email'
type ContactPerson2Props = { type ContactPerson2Props = {
contact: null | string | undefined | ContactPerson contact: null | string | undefined | ContactPerson
@ -12,6 +14,8 @@ export const ContactPerson2 = ({contact, photo}: ContactPerson2Props) => {
return "Unbekannt" return "Unbekannt"
} }
const { user, domain } = contact.email ? extractEmailPartsEncoded(contact.email) : { user: '', domain: '' }
return ( return (
<div className={styles.contact}> <div className={styles.contact}>
{ photo && { photo &&
@ -24,9 +28,9 @@ export const ContactPerson2 = ({contact, photo}: ContactPerson2Props) => {
} }
<div> <div>
{contact.name} {contact.name}
{contact.email && {user && domain &&
<> <>
<br/> <a href={`mailto:${contact.email}`} className={styles.hover}>{contact.email}</a> <br/> <SecureEmail user={user} domain={domain} />
</> </>
} }
{ {

View file

@ -17,7 +17,7 @@
color: inherit; color: inherit;
} }
.hover:hover { .contact a:hover {
text-decoration: underline; text-decoration: underline;
} }

View file

@ -1,6 +1,8 @@
import styles from './styles.module.scss' import styles from './styles.module.scss'
import { ContactPerson } from '@/payload-types' import { ContactPerson } from '@/payload-types'
import Image, { StaticImageData } from 'next/image' import Image, { StaticImageData } from 'next/image'
import { SecureEmail } from '@/components/SecureEmail/SecureEmail'
import { extractEmailPartsEncoded } from '@/utils/dto/email'
type ContactPersonCardProps = { type ContactPersonCardProps = {
contact: null | string | undefined | ContactPerson contact: null | string | undefined | ContactPerson
@ -19,6 +21,13 @@ export const ContactPersonCard = ({
return null return null
} }
let user, domain = null;
if (contact.email) {
let data = extractEmailPartsEncoded(contact.email)
user = data.user;
domain = data.domain;
}
return ( return (
<div className={styles.card}> <div className={styles.card}>
<div> <div>
@ -38,19 +47,20 @@ export const ContactPersonCard = ({
{contact.role && ( {contact.role && (
<h3 className={styles.role}>{contact.role}</h3> <h3 className={styles.role}>{contact.role}</h3>
)} )}
<div className={styles.info}> <div className={styles.name}>{contact.name}</div>
<span className={styles.name}>{contact.name}</span> <div className={styles.phoneEmail}>
{contact.email && ( {user && domain && (
<a href={`mailto:${contact.email}`} className={styles.link}> <span> <SecureEmail user={user} domain={domain} /></span>
{contact.email}
</a>
)} )}
{contact.telephone && ( {contact.telephone && (
<a href={`tel:${contact.telephone}`} className={styles.link}> <span>
📞 <a href={`tel:${contact.telephone}`}>
{contact.telephone} {contact.telephone}
</a> </a>
</span>
)} )}
</div> </div>
</div> </div>
</div> </div>

View file

@ -20,21 +20,37 @@
margin: 0; margin: 0;
} }
.info { .phoneEmail {
display: flex; display: flex;
flex-direction: column; gap: 15px;
gap: 4px;
} }
.name { .name {
font-weight: bold; font-weight: bold;
} }
.link { .phoneEmail a {
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
&:hover { &:hover {
text-decoration: underline; 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
}
} }

View file

@ -0,0 +1,23 @@
import { Meta, StoryObj } from '@storybook/react'
import { SecureEmail } from './SecureEmail'
const meta: Meta<typeof SecureEmail> = {
component: SecureEmail,
}
type Story = StoryObj<typeof SecureEmail>;
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'
}
}

View file

@ -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<HTMLAnchorElement>(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<HTMLAnchorElement>) => {
if (!ref.current?.href.startsWith('mailto:')) {
e.preventDefault();
reveal();
window.location.href = ref.current!.href;
}
};
return (
<a
ref={ref}
href="#"
onMouseEnter={reveal}
onFocus={reveal}
onTouchStart={reveal}
onClick={handleClick}>
{label}
</a>
);
}

18
src/utils/dto/email.ts Normal file
View file

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