Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/graphql/graphqlSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -77,6 +79,7 @@ export function getSchema() {
${projectTypes}
${projectInputs}
${settingsTypes}
${contactTypes}
type Query {
${accountQueries}
${categoryQueries}
Expand All @@ -97,6 +100,7 @@ export function getSchema() {
${projectMutations}
${mediaMutations}
${settingsMutations}
${contactMutation}
}
`);
}
Expand All @@ -116,5 +120,6 @@ export function getRoot() {
...projectResolver,
...mediaResolver,
...settingsResolver,
...contactResolver,
};
}
125 changes: 125 additions & 0 deletions src/graphql/resolvers/contactResolver.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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;
20 changes: 20 additions & 0 deletions src/graphql/schemas/contactSchema.ts
Original file line number Diff line number Diff line change
@@ -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!
`;
11 changes: 11 additions & 0 deletions src/types/contactTypes.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading