diff --git a/client/src/App.tsx b/client/src/App.tsx index 8c7597e..eeb8c86 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -13,6 +13,7 @@ import AdminApprenants from "./pages/AdminApprenants"; import AdminSequenceInscrits from "./pages/AdminSequenceInscrits"; import AdminUsers from "./pages/AdminUsers"; import AdminEmailTemplates from "./pages/AdminEmailTemplates"; +import AdminEmailConfig from "./pages/AdminEmailConfig"; import Inscription from "./pages/Inscription"; function Router() { @@ -28,6 +29,7 @@ function Router() { + {/* Final fallback route */} diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index af6f5d9..c88a2a6 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -21,7 +21,7 @@ import { } from "@/components/ui/sidebar"; import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const"; import { useIsMobile } from "@/hooks/useMobile"; -import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, BarChart3, UserCog, Mail } from "lucide-react"; +import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, BarChart3, UserCog, Mail, Settings } from "lucide-react"; import { CSSProperties, useEffect, useRef, useState } from "react"; import { useLocation } from "wouter"; import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton'; @@ -34,6 +34,7 @@ const menuItems = [ { icon: Users, label: "Apprenants", path: "/admin/apprenants" }, { icon: UserCog, label: "Utilisateurs", path: "/admin/users" }, { icon: Mail, label: "Templates d'emails", path: "/admin/email-templates" }, + { icon: Settings, label: "Configuration SMTP", path: "/admin/email-config" }, { icon: BarChart3, label: "Rapport Public Cible", path: "/admin/rapport-public-cible" }, ]; diff --git a/client/src/pages/AdminEmailConfig.tsx b/client/src/pages/AdminEmailConfig.tsx new file mode 100644 index 0000000..915f337 --- /dev/null +++ b/client/src/pages/AdminEmailConfig.tsx @@ -0,0 +1,323 @@ +import { useEffect, useState } from "react"; +import DashboardLayout from "@/components/DashboardLayout"; +import { trpc } from "@/lib/trpc"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { Switch } from "@/components/ui/switch"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { AlertCircle, CheckCircle2, Mail, Send } from "lucide-react"; + +export default function AdminEmailConfig() { + const { data: config, isLoading, refetch } = trpc.emailConfig.get.useQuery(); + const upsertMutation = trpc.emailConfig.upsert.useMutation(); + const testEmailMutation = trpc.emailConfig.testEmail.useMutation(); + + const [formData, setFormData] = useState({ + provider: "resend", + apiKey: "", + fromEmail: "", + fromName: "Formation Manager Itinova", + mode: "simulation" as "simulation" | "production", + domainVerified: false, + }); + + const [testEmail, setTestEmail] = useState(""); + + // Charger la configuration existante + useEffect(() => { + if (config) { + setFormData({ + provider: config.provider, + apiKey: config.apiKey || "", + fromEmail: config.fromEmail, + fromName: config.fromName, + mode: config.mode, + domainVerified: config.domainVerified, + }); + } + }, [config]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + upsertMutation.mutate(formData, { + onSuccess: () => { + toast.success("Configuration enregistrée avec succès"); + refetch(); + }, + onError: (error) => { + toast.error(`Erreur: ${error.message}`); + }, + }); + }; + + const handleTestEmail = () => { + if (!testEmail) { + toast.error("Veuillez saisir une adresse email"); + return; + } + + testEmailMutation.mutate( + { toEmail: testEmail }, + { + onSuccess: (data) => { + if (data.success) { + toast.success(data.message || "Email de test envoyé avec succès"); + } else { + toast.error(data.message || "Échec de l'envoi de l'email de test"); + } + }, + onError: (error) => { + toast.error(`Erreur: ${error.message}`); + }, + } + ); + }; + + if (isLoading) { + return ( + +
+

Chargement...

+
+
+ ); + } + + return ( + +
+
+

Configuration des emails

+

+ Configurez l'envoi d'emails via Resend et basculez entre mode simulation et production +

+
+ +
+ {/* Statut actuel */} + + + + + Statut actuel + + + +
+
+
+

Mode d'envoi

+

+ {formData.mode === "simulation" + ? "Les emails sont simulés et envoyés comme notifications" + : "Les emails sont envoyés via Resend"} +

+
+
+ {formData.mode === "production" ? "Production" : "Simulation"} +
+
+ + {formData.apiKey && ( + + + + Clé API Resend configurée + + + )} + + {!formData.apiKey && ( + + + + Aucune clé API configurée. Les emails seront simulés. + + + )} +
+
+
+ + {/* Formulaire de configuration */} + + + Configuration Resend + + Configurez votre compte Resend pour envoyer de vrais emails + + + +
+
+ + setFormData({ ...formData, apiKey: e.target.value })} + /> +

+ Obtenez votre clé API sur{" "} + + resend.com/api-keys + +

+
+ +
+ + setFormData({ ...formData, fromEmail: e.target.value })} + required + /> +

+ L'adresse email doit être vérifiée dans votre compte Resend +

+
+ +
+ + setFormData({ ...formData, fromName: e.target.value })} + required + /> +
+ +
+
+ +

+ Activer l'envoi réel d'emails (désactiver pour simuler) +

+
+ + setFormData({ ...formData, mode: checked ? "production" : "simulation" }) + } + /> +
+ +
+ +
+
+
+
+ + {/* Test d'envoi */} + + + + + Test d'envoi + + + Envoyez un email de test pour vérifier votre configuration + + + +
+
+ + setTestEmail(e.target.value)} + /> +
+ + + + {formData.mode === "simulation" && ( + + + + Mode simulation actif. L'email de test sera simulé et envoyé comme notification au propriétaire. + + + )} +
+
+
+ + {/* Documentation */} + + + Guide de configuration + + +
+
+

1. Créer un compte Resend

+

+ Inscrivez-vous gratuitement sur{" "} + + resend.com + {" "} + (100 emails/jour gratuits) +

+
+ +
+

2. Vérifier votre domaine

+

+ Ajoutez et vérifiez votre domaine dans les paramètres Resend pour pouvoir envoyer des emails +

+
+ +
+

3. Obtenir la clé API

+

+ Générez une clé API dans la section "API Keys" de votre compte Resend +

+
+ +
+

4. Configurer et tester

+

+ Saisissez vos informations ci-dessus, enregistrez, puis testez l'envoi avant de passer en mode production +

+
+
+
+
+
+
+
+ ); +} diff --git a/drizzle/0009_polite_senator_kelly.sql b/drizzle/0009_polite_senator_kelly.sql new file mode 100644 index 0000000..ecda2d8 --- /dev/null +++ b/drizzle/0009_polite_senator_kelly.sql @@ -0,0 +1,13 @@ +CREATE TABLE `emailConfig` ( + `id` int AUTO_INCREMENT NOT NULL, + `provider` varchar(50) NOT NULL DEFAULT 'resend', + `apiKey` text, + `fromEmail` varchar(320) NOT NULL, + `fromName` varchar(255) NOT NULL DEFAULT 'Formation Manager Itinova', + `mode` enum('simulation','production') NOT NULL DEFAULT 'simulation', + `domainVerified` boolean NOT NULL DEFAULT false, + `active` boolean NOT NULL DEFAULT true, + `createdAt` timestamp NOT NULL DEFAULT (now()), + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `emailConfig_id` PRIMARY KEY(`id`) +); diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..0afc780 --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,779 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "a099e856-0b0c-430d-8160-ca950c994fe5", + "prevId": "73d62a50-d4a5-41dd-b5a2-4d2d72b7f119", + "tables": { + "apprenants": { + "name": "apprenants", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prenom": { + "name": "prenom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeEtablissement": { + "name": "codeEtablissement", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fonction": { + "name": "fonction", + "type": "enum('directeur','chef_service','autre')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "apprenants_id": { + "name": "apprenants_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "apprenants_email_unique": { + "name": "apprenants_email_unique", + "columns": [ + "email" + ] + } + }, + "checkConstraint": {} + }, + "datesFormation": { + "name": "datesFormation", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "sequenceId": { + "name": "sequenceId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateDebut": { + "name": "dateDebut", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateFin": { + "name": "dateFin", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ordre": { + "name": "ordre", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "datesFormation_id": { + "name": "datesFormation_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "emailConfig": { + "name": "emailConfig", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "provider": { + "name": "provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'resend'" + }, + "apiKey": { + "name": "apiKey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fromEmail": { + "name": "fromEmail", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fromName": { + "name": "fromName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Formation Manager Itinova'" + }, + "mode": { + "name": "mode", + "type": "enum('simulation','production')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'simulation'" + }, + "domainVerified": { + "name": "domainVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "emailConfig_id": { + "name": "emailConfig_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "emailTemplates": { + "name": "emailTemplates", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logoUrl": { + "name": "logoUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "primaryColor": { + "name": "primaryColor", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#2563eb'" + }, + "headerBgColor": { + "name": "headerBgColor", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#2563eb'" + }, + "headerTextColor": { + "name": "headerTextColor", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#ffffff'" + }, + "headerTitle": { + "name": "headerTitle", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Formation Manager Itinova'" + }, + "footerText": { + "name": "footerText", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "emailTemplates_id": { + "name": "emailTemplates_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "emailTemplates_type_unique": { + "name": "emailTemplates_type_unique", + "columns": [ + "type" + ] + } + }, + "checkConstraint": {} + }, + "formations": { + "name": "formations", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lienUnique": { + "name": "lienUnique", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actif": { + "name": "actif", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "formations_id": { + "name": "formations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "formations_lienUnique_unique": { + "name": "formations_lienUnique_unique", + "columns": [ + "lienUnique" + ] + } + }, + "checkConstraint": {} + }, + "inscriptions": { + "name": "inscriptions", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "apprenantId": { + "name": "apprenantId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sequenceId": { + "name": "sequenceId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "statut": { + "name": "statut", + "type": "enum('confirmee','liste_attente','annulee')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateInscription": { + "name": "dateInscription", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "inscriptions_id": { + "name": "inscriptions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "passwordResetTokens": { + "name": "passwordResetTokens", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used": { + "name": "used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "passwordResetTokens_id": { + "name": "passwordResetTokens_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "passwordResetTokens_token_unique": { + "name": "passwordResetTokens_token_unique", + "columns": [ + "token" + ] + } + }, + "checkConstraint": {} + }, + "sequences": { + "name": "sequences", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "formationId": { + "name": "formationId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lieu": { + "name": "lieu", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "publicCible": { + "name": "publicCible", + "type": "enum('directeur','chef_service','autre')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capaciteMax": { + "name": "capaciteMax", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 12 + }, + "dateBlocage": { + "name": "dateBlocage", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "statut": { + "name": "statut", + "type": "enum('ouverte','bloquee','terminee')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ouverte'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sequences_id": { + "name": "sequences_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "openId": { + "name": "openId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "loginMethod": { + "name": "loginMethod", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('user','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastSignedIn": { + "name": "lastSignedIn", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_openId_unique": { + "name": "users_openId_unique", + "columns": [ + "openId" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 3132a5b..5dc40cf 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1763416801277, "tag": "0008_jittery_fenris", "breakpoints": true + }, + { + "idx": 9, + "version": "5", + "when": 1763448844826, + "tag": "0009_polite_senator_kelly", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index a0b47a4..c68adc0 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -171,3 +171,30 @@ export const emailTemplates = mysqlTable("emailTemplates", { export type EmailTemplate = typeof emailTemplates.$inferSelect; export type InsertEmailTemplate = typeof emailTemplates.$inferInsert; + +/** + * Table de configuration SMTP/Email + * Stocke la configuration pour l'envoi d'emails (Resend, etc.) + */ +export const emailConfig = mysqlTable("emailConfig", { + id: int("id").autoincrement().primaryKey(), + /** Service d'envoi (resend, smtp) */ + provider: varchar("provider", { length: 50 }).default("resend").notNull(), + /** Clé API Resend (chiffrée) */ + apiKey: text("apiKey"), + /** Email expéditeur */ + fromEmail: varchar("fromEmail", { length: 320 }).notNull(), + /** Nom de l'expéditeur */ + fromName: varchar("fromName", { length: 255 }).default("Formation Manager Itinova").notNull(), + /** Mode: simulation ou production */ + mode: mysqlEnum("mode", ["simulation", "production"]).default("simulation").notNull(), + /** Domaine vérifié (pour Resend) */ + domainVerified: boolean("domainVerified").default(false).notNull(), + /** Actif ou non */ + active: boolean("active").default(true).notNull(), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type EmailConfig = typeof emailConfig.$inferSelect; +export type InsertEmailConfig = typeof emailConfig.$inferInsert; diff --git a/server/_core/emailSender.ts b/server/_core/emailSender.ts index 7e14d9c..0253085 100644 --- a/server/_core/emailSender.ts +++ b/server/_core/emailSender.ts @@ -11,6 +11,7 @@ import { ENV } from "./env"; import { notifyOwner } from "./notification"; +import { getActiveEmailConfig } from "../db"; interface EmailParams { to: string; @@ -37,9 +38,11 @@ interface ResendEmailRequest { /** * Envoie un email via Resend API */ -async function sendViaResend(params: EmailParams): Promise { - const resendApiKey = process.env.RESEND_API_KEY; - const fromEmail = process.env.RESEND_FROM_EMAIL || 'noreply@manusvm.computer'; +async function sendViaResend(params: EmailParams, config?: { apiKey: string; fromEmail: string; fromName: string }): Promise { + // Utiliser la config fournie ou les variables d'environnement + const resendApiKey = config?.apiKey || process.env.RESEND_API_KEY; + const fromEmail = config?.fromEmail || process.env.RESEND_FROM_EMAIL || 'noreply@manusvm.computer'; + const fromName = config?.fromName || 'Formation Manager Itinova'; if (!resendApiKey) { console.warn('[Email] RESEND_API_KEY non configurée, email simulé'); @@ -48,7 +51,7 @@ async function sendViaResend(params: EmailParams): Promise { try { const payload: ResendEmailRequest = { - from: fromEmail, + from: `${fromName} <${fromEmail}>`, to: [params.to], subject: params.subject, html: params.html, @@ -126,15 +129,32 @@ ${params.attachments ? `Pièces jointes: ${params.attachments.map(a => a.filenam * Envoie un email (réel ou simulé selon la configuration) */ export async function sendEmail(params: EmailParams): Promise { - // Tenter d'envoyer via Resend - const sent = await sendViaResend(params); + // Récupérer la configuration depuis la base de données + const config = await getActiveEmailConfig(); - // Si l'envoi échoue (pas de clé API ou erreur), simuler - if (!sent) { + // Si mode simulation ou pas de config, simuler + if (!config || config.mode === 'simulation') { return await simulateEmail(params); } - return true; + // Si mode production, tenter d'envoyer via Resend + if (config.mode === 'production' && config.apiKey) { + const sent = await sendViaResend(params, { + apiKey: config.apiKey, + fromEmail: config.fromEmail, + fromName: config.fromName, + }); + + // Si l'envoi échoue, simuler en fallback + if (!sent) { + return await simulateEmail(params); + } + + return true; + } + + // Fallback: simuler + return await simulateEmail(params); } /** diff --git a/server/db.ts b/server/db.ts index 17f6454..b51bf4d 100644 --- a/server/db.ts +++ b/server/db.ts @@ -10,16 +10,18 @@ import { inscriptions, passwordResetTokens, InsertPasswordResetToken, - InsertFormation, - InsertApprenant, + emailTemplates, + emailConfig, + InsertEmailConfig, InsertSequence, InsertDateFormation, InsertInscription, + InsertFormation, + InsertApprenant, Sequence, DateFormation, Apprenant, Formation, - emailTemplates, EmailTemplate, InsertEmailTemplate } from "../drizzle/schema"; @@ -602,3 +604,55 @@ export async function initializeDefaultEmailTemplates() { console.log("[DB] Templates d'emails par défaut initialisés"); } + + +// ==================== Email Config ==================== + +/** + * Récupère la configuration email active + */ +export async function getActiveEmailConfig() { + const db = await getDb(); + if (!db) return undefined; + + const result = await db + .select() + .from(emailConfig) + .where(eq(emailConfig.active, true)) + .limit(1); + + return result.length > 0 ? result[0] : undefined; +} + +/** + * Crée ou met à jour la configuration email + */ +export async function upsertEmailConfig(data: InsertEmailConfig) { + const db = await getDb(); + if (!db) return; + + // Désactiver toutes les configurations existantes + await db.update(emailConfig).set({ active: false }); + + // Créer la nouvelle configuration + await db.insert(emailConfig).values({ + ...data, + active: true, + }); +} + +/** + * Met à jour la configuration email + */ +export async function updateEmailConfig(id: number, data: Partial) { + const db = await getDb(); + if (!db) return; + + await db + .update(emailConfig) + .set({ + ...data, + updatedAt: new Date(), + }) + .where(eq(emailConfig.id, id)); +} diff --git a/server/routers.ts b/server/routers.ts index b87256c..c70a996 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -666,6 +666,51 @@ export const appRouter = router({ return { success: true }; }), }), + + emailConfig: router({ + get: adminProcedure.query(async () => { + return db.getActiveEmailConfig(); + }), + + upsert: adminProcedure + .input(z.object({ + provider: z.string(), + apiKey: z.string().nullable(), + fromEmail: z.string().email(), + fromName: z.string(), + mode: z.enum(["simulation", "production"]), + domainVerified: z.boolean(), + })) + .mutation(async ({ input }) => { + await db.upsertEmailConfig(input); + return { success: true }; + }), + + testEmail: adminProcedure + .input(z.object({ + toEmail: z.string().email(), + })) + .mutation(async ({ input }) => { + // Importer le service d'envoi + const { sendEmail } = await import("./_core/emailSender"); + + try { + const success = await sendEmail({ + to: input.toEmail, + subject: "Test d'envoi d'email - Formation Manager Itinova", + html: ` +

Test réussi !

+

Cet email de test a été envoyé avec succès depuis votre configuration SMTP.

+

Votre configuration d'envoi d'emails fonctionne correctement.

+ `, + }); + + return { success, message: success ? "Email envoyé avec succès" : "Échec de l'envoi" }; + } catch (error: any) { + return { success: false, message: error.message }; + } + }), + }), }); export type AppRouter = typeof appRouter; diff --git a/todo.md b/todo.md index fc115ba..cbb9951 100644 --- a/todo.md +++ b/todo.md @@ -227,3 +227,17 @@ - [x] Modifier le service d'envoi pour utiliser les templates personnalisés - [x] Ajouter le lien dans le menu de navigation - [x] Tester la personnalisation et l'envoi avec templates personnalisés + +## Configuration SMTP via interface + +- [x] Créer la table email_config dans le schéma +- [x] Créer les procédures backend pour CRUD de la configuration +- [x] Ajouter une procédure de test d'envoi d'email +- [x] Modifier le service d'envoi pour utiliser la config en base +- [x] Créer la page AdminEmailConfig +- [x] Implémenter le formulaire de configuration (API key, email expéditeur, mode) +- [x] Ajouter le test d'envoi immédiat avec feedback +- [x] Ajouter la validation de domaine +- [x] Ajouter le basculement simulation/production +- [x] Ajouter le lien dans le menu de navigation +- [x] Tester la configuration et l'envoi d'emails