feat: add the contact form

This commit is contained in:
Dorian Niemiec 2024-11-07 21:54:34 +01:00
parent e59ef80353
commit 9aaf452310
7 changed files with 473 additions and 4 deletions

View file

@ -1 +1,12 @@
NEXT_PUBLIC_WEBSITE_URL=
NEXT_PUBLIC_WEBSITE_URL=
EMAIL_SERVER=
EMAIL_PORT=
EMAIL_SECURE=
EMAIL_USER=
EMAIL_PASS=
EMAIL_CONTACT_ADDRESS=
EMAIL_CONTACT_DEST=
NEXT_PUBLIC_HCAPTCHA_SITE_KEY=
HCAPTCHA_SECRET=

View file

@ -0,0 +1,37 @@
export const metadata = {
title: "Contact Us - MERNMail",
description:
"Have questions about MERNMail? Need technical support? Visit our Contact Us page to find various ways to get in touch with our team, including email, and our official support channel.",
openGraph: {
title: "Contact Us - MERNMail",
description:
"Have questions about MERNMail? Need technical support? Visit our Contact Us page to find various ways to get in touch with our team, including email, and our official support channel.",
url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/contact`,
type: "website",
images: [
{
url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/metadata/mernmail-cover.png`,
width: 2560,
height: 1440,
alt: "Contact Us - MERNMail"
}
]
},
twitter: {
card: "summary_large_image",
site: "@MERNMail",
title: "Contact Us - MERNMail",
description:
"Have questions about MERNMail? Need technical support? Visit our Contact Us page to find various ways to get in touch with our team, including email, and our official support channel.",
images: [
`${process.env.NEXT_PUBLIC_WEBSITE_URL}/metadata/mernmail-cover.png`
],
creator: "@MERNMail"
}
};
const ContactLayout = ({ children }) => {
return <>{children}</>;
};
export default ContactLayout;

138
app/(root)/contact/page.jsx Normal file
View file

@ -0,0 +1,138 @@
"use client";
import HCaptcha from "@hcaptcha/react-hcaptcha";
import { Send } from "lucide-react";
import { useRef, useState } from "react";
import { emails } from "@/constants";
import { isEmail } from "validator";
import SocialIcons from "@/components/SocialIcons";
function Contact() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
const [status, setStatus] = useState("");
const [hCaptchaToken, setHCaptchaToken] = useState(null);
const captchaRef = useRef();
return (
<main className="max-w-screen-lg mx-auto px-4 py-6 gap-2 flex flex-col">
<h1 className="text-center text-4xl md:text-6xl py-12 md:py-16 font-bold">
Contact us
</h1>
<div className="flex flex-col md:flex-row mb-6">
<form
className="border-border border bg-card text-card-foreground rounded-lg mx-2 md:mx-4 max-md:mb-8 p-6 w-full self-center"
onSubmit={async (e) => {
e.preventDefault();
try {
const res = await fetch("/api/contact", {
method: "POST",
body: JSON.stringify({
captchaToken: hCaptchaToken,
name: name,
email: email,
message: message
}),
headers: {
"Content-Type": "application/json",
Accept: "application/json"
}
});
if (res.status == 200) {
setStatus("Your message has been sent.");
} else {
setStatus("Uh oh! Something went wrong.");
}
} catch (error) {
console.error(error);
setStatus("Uh oh! Something went wrong.");
}
setHCaptchaToken(null);
captchaRef.current.resetCaptcha();
setName("");
setEmail("");
setMessage("");
}}
>
<label htmlFor="contact-name" className="block mb-2">
Name
</label>
<input
onChange={(e) => {
setName(e.target.value);
}}
value={name}
id="contact-name"
className="block mb-4 bg-accent text-accent-foreground w-full rounded-md px-2 py-1 focus:outline focus:outline-2 focus:outline-primary"
/>
<label htmlFor="contact-email" className="block mb-2">
Email
</label>
<input
type="email"
onChange={(e) => {
setEmail(e.target.value);
}}
value={email}
id="contact-email"
className="block mb-4 bg-accent text-accent-foreground w-full rounded-md px-2 py-1 focus:outline focus:outline-2 focus:outline-primary"
/>
<label htmlFor="contact-message" className="block mb-2">
Message
</label>
<textarea
onChange={(e) => {
setMessage(e.target.value);
}}
value={message}
id="contact-message"
className="block h-44 bg-accent text-accent-foreground w-full rounded-md px-2 py-1 focus:outline focus:outline-2 focus:outline-primary"
/>
<div className="my-5">
<HCaptcha
ref={captchaRef}
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY}
onVerify={(token) => {
setHCaptchaToken(token);
}}
/>
</div>
{status ? <p className="my-5">{status}</p> : ""}
<button
type="submit"
className="bg-primary text-primary-foreground p-2 rounded-md text-center w-full hover:bg-primary/75 disabled:bg-primary/50 transition-colors"
disabled={!name || !email || !isEmail(email) || !message}
>
<Send className="inline align-top mr-2" />
<span className="align-middle">Send</span>
</button>
</form>
<div className="border-border border bg-card text-card-foreground rounded-lg mx-2 md:mx-4 px-9 py-6 w-full md:max-w-96 self-center">
<ul className="mb-6">
{emails.map((email, index) => (
<li key={index} className="flex items-center my-4">
<email.icon className="mr-2 shrink-0" size={24} />
<span>
<a
href={email.url}
title={`Send an email to ${email.email}`}
className="text-muted-foreground hover:text-accent-foreground transition-colors duration-200"
>
{email.email}
</a>
</span>
</li>
))}
</ul>
<div className="text-center space-x-3 py-4 border-border border-t border-b">
<SocialIcons />
</div>
</div>
</div>
</main>
);
}
export default Contact;

195
app/api/contact/route.js Normal file
View file

@ -0,0 +1,195 @@
import nodemailer from "nodemailer";
import { NextResponse } from "next/server";
import dns from "dns/promises";
import { isEmail, escape } from "validator";
const CONTACT_MESSAGE_FIELDS = {
name: "Name",
email: "Email",
message: "Message"
};
export const transporter = nodemailer.createTransport({
host: process.env.EMAIL_SERVER,
port: parseInt(process.env.EMAIL_PORT ? process.env.EMAIL_PORT : "25"),
secure: Boolean(process.env.EMAIL_SECURE ? process.env.EMAIL_SECURE : false),
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS
}
});
const generateEmailContent = (data) => {
const stringData = Object.entries(data).reduce(
(str, [key, val]) =>
str +
(key == "captchaToken"
? ""
: `${CONTACT_MESSAGE_FIELDS[key] || key}: ${val.replace(/\n/g, "\n")} \n\n`),
""
);
const htmlData = Object.entries(data).reduce(
(str, [key, val]) =>
str +
(key == "captchaToken"
? ""
: `<h3 class="form-heading">${escape(
CONTACT_MESSAGE_FIELDS[key] || key
)}</h3><p class="form-answer">${escape(val).replace(
/\n/g,
"<br/>"
)}</p>`),
""
);
return {
text: stringData,
html: `<!DOCTYPE html>
<html>
<head>
<title>Contact Email</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<style type="text/css">
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
table { border-collapse: collapse !important; }
body { height: 100% !important; margin: 0 !important; padding: 0 !important; width: 100% !important; }
@media screen and (max-width: 525px) {
.wrapper { width: 100% !important; max-width: 100% !important; }
.responsive-table { width: 100% !important; }
.padding { padding: 10px 5% 15px 5% !important; }
.section-padding { padding: 0 15px 50px 15px !important; }
}
.form-container { margin-bottom: 24px; padding: 20px; border: 1px dashed #ccc; }
.form-heading { color: #2a2a2a; font-family: "Helvetica Neue", "Helvetica", "Arial", sans-serif; font-weight: 400; text-align: left; line-height: 20px; font-size: 18px; margin: 0 0 8px; padding: 0; }
.form-answer { color: #2a2a2a; font-family: "Helvetica Neue", "Helvetica", "Arial", sans-serif; font-weight: 300; text-align: left; line-height: 20px; font-size: 16px; margin: 0 0 24px; padding: 0; }
div[style*="margin: 16px 0;"] { margin: 0 !important; }
</style>
</head>
<body style="margin: 0 !important; padding: 0 !important; background: #fff">
<div style="display: none; font-size: 1px; color: #fefefe; line-height: 1px; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden;"></div>
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 10px 15px 30px 15px" class="section-padding">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 500px" class="responsive-table">
<tr>
<td>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td style="padding: 0 0 0 0; font-size: 16px; line-height: 25px; color: #232323;" class="padding message-content">
<h2>New Contact Message</h2>
<div class="form-container">${htmlData}</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`
};
};
export async function POST(req) {
if (req.method !== "POST") {
return NextResponse.json(
{ message: "Method Not Allowed" },
{ status: 405 }
);
}
try {
const data = await req.json();
console.log(data);
// Verify hCaptcha token
const hcaptchaResponse = await fetch(
`https://api.hcaptcha.com/siteverify`,
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: `secret=${process.env.HCAPTCHA_SECRET}&response=${data.captchaToken}`
}
);
const hcaptchaData = await hcaptchaResponse.json();
if (!hcaptchaData.success) {
return NextResponse.json(
{ message: "Captcha verification failed." },
{ status: 400 }
);
}
// Check email address
if (!isEmail(data.email)) {
return NextResponse.json(
{ message: "Invalid email address" },
{ status: 400 }
);
}
// Check email host
const emailDomainMatch = data.email.match(/@([^@]+)/);
const emailDomain = emailDomainMatch ? emailDomainMatch[1] : "";
let isEmailHostValid = false;
try {
const mxRecords = await dns.resolveMx(emailDomain);
if (mxRecords.length > 0) {
for (let i = 0; i < mxRecords.length; i++) {
try {
const aRecords = await dns.resolve4(mxRecords[i].exchange);
if (aRecords.length > 0) {
isEmailHostValid = true;
break;
}
} catch (err) {}
try {
const aaaaRecords = await dns.resolve6(mxRecords[i].exchange);
if (aaaaRecords.length > 0) {
isEmailHostValid = true;
break;
}
} catch (err) {}
}
}
} catch (err) {}
if (!isEmailHostValid) {
return NextResponse.json(
{ message: "Email domain is misconfigured" },
{ status: 400 }
);
}
await transporter.sendMail({
from: process.env.EMAIL_CONTACT_ADDRESS,
to: process.env.EMAIL_CONTACT_DEST,
subject: "Contact Email",
...generateEmailContent(data)
});
return NextResponse.json(
{ message: "Email sent successfully" },
{ status: 200 }
);
} catch (error) {
console.error("Error sending email:", error);
return NextResponse.json(
{ message: "Internal Server Error" },
{ status: 500 }
);
}
}

View file

@ -1,4 +1,13 @@
import { Bird, MonitorSmartphone, Server, Sparkles } from "lucide-react";
import {
Bird,
Bug,
Mail,
MonitorSmartphone,
Server,
ShieldCheck,
Sparkles,
WebhookIcon
} from "lucide-react";
export const headerLinks = {
nav: [
@ -111,3 +120,26 @@ export const footerLinks = {
}
}
};
export const emails = [
{
icon: Mail,
email: "support@mernmail.org",
url: "mailto:support@mernmail.org"
},
{
icon: WebhookIcon,
email: "webmaster@mernmail.org",
url: "mailto:webmaster@mernmail.org"
},
{
icon: Bug,
email: "bugreports@mernmail.org",
url: "mailto:bugreports@mernmail.org"
},
{
icon: ShieldCheck,
email: "vulnerability-reports@mernmail.org",
url: "mailto:vulnerability-reports@mernmail.org"
}
];

55
package-lock.json generated
View file

@ -8,17 +8,20 @@
"name": "mernmail-website",
"version": "0.1.0",
"dependencies": {
"@hcaptcha/react-hcaptcha": "^1.11.0",
"@tailwindcss/typography": "^0.5.15",
"globby": "^14.0.2",
"lucide-react": "^0.454.0",
"next": "^15.0.2",
"next-themes": "^0.4.3",
"nodemailer": "^6.9.16",
"prismjs": "^1.29.0",
"prop-types": "^15.8.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"rehype-prism": "^2.3.3"
"rehype-prism": "^2.3.3",
"validator": "^13.12.0"
},
"devDependencies": {
"@commitlint/cli": "^19.5.0",
@ -72,6 +75,17 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz",
"integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@commitlint/cli": {
"version": "19.5.0",
"resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-19.5.0.tgz",
@ -530,6 +544,24 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@hcaptcha/loader": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@hcaptcha/loader/-/loader-1.2.4.tgz",
"integrity": "sha512-3MNrIy/nWBfyVVvMPBKdKrX7BeadgiimW0AL/a/8TohNtJqxoySKgTJEXOQvYwlHemQpUzFrIsK74ody7JiMYw=="
},
"node_modules/@hcaptcha/react-hcaptcha": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/@hcaptcha/react-hcaptcha/-/react-hcaptcha-1.11.0.tgz",
"integrity": "sha512-UKHtzzVMHLTGwab5pgV96UbcXdyh5Qyq8E0G5DTyXq8txMvuDx7rSyC+BneOjWVW0a7O9VuZmkg/EznVLRE45g==",
"dependencies": {
"@babel/runtime": "^7.17.9",
"@hcaptcha/loader": "^1.2.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@ -8065,6 +8097,14 @@
"integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==",
"dev": true
},
"node_modules/nodemailer": {
"version": "6.9.16",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.16.tgz",
"integrity": "sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@ -8977,6 +9017,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz",
@ -10507,6 +10552,14 @@
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/validator": {
"version": "13.12.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz",
"integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",

View file

@ -12,17 +12,20 @@
"cz": "cz"
},
"dependencies": {
"@hcaptcha/react-hcaptcha": "^1.11.0",
"@tailwindcss/typography": "^0.5.15",
"globby": "^14.0.2",
"lucide-react": "^0.454.0",
"next": "^15.0.2",
"next-themes": "^0.4.3",
"nodemailer": "^6.9.16",
"prismjs": "^1.29.0",
"prop-types": "^15.8.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"rehype-prism": "^2.3.3"
"rehype-prism": "^2.3.3",
"validator": "^13.12.0"
},
"devDependencies": {
"@commitlint/cli": "^19.5.0",