diff --git a/.env.example b/.env.example
index 09e74cc..8b01447 100644
--- a/.env.example
+++ b/.env.example
@@ -1 +1,12 @@
-NEXT_PUBLIC_WEBSITE_URL=
\ No newline at end of file
+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=
\ No newline at end of file
diff --git a/app/(root)/contact/layout.jsx b/app/(root)/contact/layout.jsx
new file mode 100644
index 0000000..dd0c6b7
--- /dev/null
+++ b/app/(root)/contact/layout.jsx
@@ -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;
diff --git a/app/(root)/contact/page.jsx b/app/(root)/contact/page.jsx
new file mode 100644
index 0000000..9dfd5b1
--- /dev/null
+++ b/app/(root)/contact/page.jsx
@@ -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 (
+
+
+ Contact us
+
+
+
+ );
+}
+
+export default Contact;
diff --git a/app/api/contact/route.js b/app/api/contact/route.js
new file mode 100644
index 0000000..0d7f792
--- /dev/null
+++ b/app/api/contact/route.js
@@ -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"
+ ? ""
+ : `
${escape(val).replace(
+ /\n/g,
+ "
"
+ )}
`),
+ ""
+ );
+
+ return {
+ text: stringData,
+ html: `
+
+
+ Contact Email
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ New Contact Message
+ ${htmlData}
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+ `
+ };
+};
+
+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 }
+ );
+ }
+}
diff --git a/constants/index.jsx b/constants/index.jsx
index 36241ca..af3fec9 100644
--- a/constants/index.jsx
+++ b/constants/index.jsx
@@ -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"
+ }
+];
diff --git a/package-lock.json b/package-lock.json
index 0e4efe7..50611e0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 1541b5f..ada0aed 100644
--- a/package.json
+++ b/package.json
@@ -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",