From 86f1e832b234b9e4495a7eecea05c4aebf31fe5a Mon Sep 17 00:00:00 2001 From: NKoelblen Date: Wed, 6 May 2026 19:22:17 +0200 Subject: [PATCH] feat: add contact form functionality with validation and email sending Co-authored-by: Copilot --- .env.example | 1 + src/graphql/graphqlSchema.ts | 5 + src/graphql/resolvers/contactResolver.ts | 125 +++++++++++++++++++++++ src/graphql/schemas/contactSchema.ts | 20 ++++ src/types/contactTypes.ts | 11 ++ 5 files changed, 162 insertions(+) create mode 100644 src/graphql/resolvers/contactResolver.ts create mode 100644 src/graphql/schemas/contactSchema.ts create mode 100644 src/types/contactTypes.ts diff --git a/.env.example b/.env.example index 305dc51..e9d2183 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,7 @@ SMTP_PORT = 1025 SMTP_USER = SMTP_PASSWORD = SMTP_FROM = noreply@example.com # optional, default noreply@example.com +CONTACT_RECIPIENT_EMAIL = hello@example.com # email address to receive contact form messages RESET_URL = http://localhost:5173/reset-password INIT_LOGIN = admin diff --git a/src/graphql/graphqlSchema.ts b/src/graphql/graphqlSchema.ts index a643500..8248a3d 100644 --- a/src/graphql/graphqlSchema.ts +++ b/src/graphql/graphqlSchema.ts @@ -55,6 +55,8 @@ import { settingsTypes, } from "./schemas/settingsSchema"; import settingsResolver from "./resolvers/settingsResolver"; +import { contactMutation, contactTypes } from "./schemas/contactSchema"; +import contactResolver from "./resolvers/contactResolver"; /** * Construit le schéma GraphQL en combinant les types, requêtes et mutations de tous les modules. @@ -77,6 +79,7 @@ export function getSchema() { ${projectTypes} ${projectInputs} ${settingsTypes} + ${contactTypes} type Query { ${accountQueries} ${categoryQueries} @@ -97,6 +100,7 @@ export function getSchema() { ${projectMutations} ${mediaMutations} ${settingsMutations} + ${contactMutation} } `); } @@ -116,5 +120,6 @@ export function getRoot() { ...projectResolver, ...mediaResolver, ...settingsResolver, + ...contactResolver, }; } diff --git a/src/graphql/resolvers/contactResolver.ts b/src/graphql/resolvers/contactResolver.ts new file mode 100644 index 0000000..f119815 --- /dev/null +++ b/src/graphql/resolvers/contactResolver.ts @@ -0,0 +1,125 @@ +import nodemailer from "nodemailer"; +import { ContactPayload } from "../../types/contactTypes"; +import validator from "validator"; +import { sanitizeString } from "../../utils/stringUtils"; +import { isEmpty } from "../../utils/validationUtils"; +import { redis } from "../../constants/abfConstants"; + +type ContactContext = { + ip?: string; +}; + +const CONTACT_ATTEMPT_PREFIX = "contact:"; +const CONTACT_ATTEMPT_WINDOW_MS = 60 * 60 * 1000; // 1 heure +const CONTACT_MAX_ATTEMPTS = 5; + +async function assertContactRateLimit(ip: string): Promise { + const key = CONTACT_ATTEMPT_PREFIX + ip; + const count = await redis.incr(key); + if (count === 1) { + await redis.pexpire(key, CONTACT_ATTEMPT_WINDOW_MS); + } + + if (count > CONTACT_MAX_ATTEMPTS) { + throw new Error( + "Vous avez atteint la limite de messages. Reessayez plus tard.", + ); + } +} + +function normalizeContactInput(args: ContactPayload): ContactPayload { + const company = args.company ? sanitizeString(args.company) : undefined; + const name = args.name ? sanitizeString(args.name) : undefined; + const rawEmail = args.email.trim().normalize("NFC"); + if (!validator.isEmail(rawEmail)) { + throw new Error("Email invalide"); + } + const email = validator.normalizeEmail(rawEmail, { + all_lowercase: true, + }); + const phone = args.phone ? sanitizeString(args.phone) : undefined; + const customSubject = args.customSubject + ? sanitizeString(args.customSubject) + : undefined; + const message = sanitizeString(args.message); + + if (!email) throw new Error("Email invalide"); + if (company && company.length > 120) { + throw new Error("Société invalide"); + } + if (name && (name.length < 2 || name.length > 120)) { + throw new Error("Nom invalide"); + } + if ( + phone && + (!validator.matches(phone, /^[+0-9().\s-]{6,30}$/) || phone.length > 30) + ) { + throw new Error("Téléphone invalide"); + } + if (!args.subject || !["project", "rdv", "other"].includes(args.subject)) { + throw new Error("Sujet invalide"); + } + if (customSubject && customSubject.length > 120) { + throw new Error("Sujet personnalisé trop long"); + } + if (isEmpty(message) || message.length < 10 || message.length > 3000) { + throw new Error("Message invalide"); + } + + return { + company, + name, + email, + phone, + subject: args.subject, + customSubject, + message, + }; +} + +const contactResolver = { + sendContact: async (_args: ContactPayload, context: ContactContext) => { + if ( + !process.env.SMTP_HOST || + !process.env.SMTP_PORT || + !process.env.CONTACT_RECIPIENT_EMAIL + ) { + throw new Error( + "SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD and CONTACT_RECIPIENT_EMAIL must be defined in environment variables", + ); + } + + const ip = context?.ip ?? "unknown"; + await assertContactRateLimit(ip); + + const args = normalizeContactInput(_args); + + const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT), + secure: false, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASSWORD, + }, + }); + await transporter.sendMail({ + from: process.env.SMTP_FROM || "noreply@example.com", + replyTo: args.email, + to: process.env.CONTACT_RECIPIENT_EMAIL, + subject: + "[Contact] " + + (args.subject === "project" + ? "Demande de collaboration" + : args.subject === "rdv" + ? "Demande de rendez-vous" + : args.customSubject + ? args.customSubject + : ""), + text: `${args.company ? "Société : " + args.company + "\n" : ""}${args.name ? "Nom : " + args.name + "\n" : ""}Email : ${args.email}\n${args.phone ? "Téléphone : " + args.phone + "\n" : ""}\n---\n\n${args.message}`, + }); + return true; + }, +}; + +export default contactResolver; diff --git a/src/graphql/schemas/contactSchema.ts b/src/graphql/schemas/contactSchema.ts new file mode 100644 index 0000000..79dcf59 --- /dev/null +++ b/src/graphql/schemas/contactSchema.ts @@ -0,0 +1,20 @@ +export const contactTypes = ` + enum ContactSubject { + project + rdv + other + } +`; + +// Mutations GraphQL pour le formulaire de contact +export const contactMutation = ` + sendContact( + company: String, + name: String, + email: String!, + phone: String, + subject: ContactSubject!, + customSubject: String, + message: String! + ): Boolean! +`; diff --git a/src/types/contactTypes.ts b/src/types/contactTypes.ts new file mode 100644 index 0000000..33ed856 --- /dev/null +++ b/src/types/contactTypes.ts @@ -0,0 +1,11 @@ +export type ContactSubject = "project" | "rdv" | "other"; + +export type ContactPayload = { + company?: string; + name?: string; + email: string; + phone?: string; + subject: ContactSubject; + customSubject?: string; + message: string; +};