From 012024a29303ab4b830f0eea087d57032c09cbe0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 15:25:00 +0000 Subject: [PATCH] feat(mails): send supplier document requests over each store's own SMTP Replaces the mailto: link with a server-side send, so the message carries the store's signature and logo instead of depending on each workstation's Outlook. Store record (groups): - address, phone and logo (data URI, 200 KB cap) feed the mail signature - per-store SMTP settings: host, port, SSL/STARTTLS, credentials, sender address and display name, with an enable switch - "test connection" button verifies the server without sending anything - the empty-form literal, previously repeated five times, becomes one constant Sending: - nodemailer transport built per store from its own settings - multipart mail: plain-text alternative plus HTML whose signature embeds the logo as an inline CID attachment, which Outlook renders without the remote image blocking that a data: URI would hit - delivery details are HTML-escaped - Reply-To set to the store address; the row shows a spinner and refuses a second click while a send is in flight Credentials: - the SMTP password is never returned to the client; a response-layer sanitizer strips it from every /api payload and replaces it with a smtpPasswordSet flag, covering the ten-plus queries that join full group rows into deliveries, orders and user relations - an empty password field on save keeps the stored one rather than clearing it Verified end-to-end against a local SMTP server: transport, auth, From/Reply-To, multipart structure and the inline logo attachment. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FHdE9nEh8zHhQNGpCgrtYe --- Dockerfile | 3 +- client/src/lib/supplierMail.ts | 123 --------- client/src/pages/BLReconciliation.tsx | 73 ++++- client/src/pages/Groups.tsx | 378 +++++++++++++++++++++----- init.sql | 13 + package-lock.json | 21 ++ package.json | 2 + server/emailService.ts | 165 +++++++++++ server/migrations.production.ts | 19 +- server/migrations.ts | 20 +- server/routes.ts | 139 ++++++++++ server/sanitize.ts | 43 +++ shared/schema.ts | 24 ++ shared/supplierMail.ts | 180 ++++++++++++ 14 files changed, 994 insertions(+), 209 deletions(-) delete mode 100644 client/src/lib/supplierMail.ts create mode 100644 server/emailService.ts create mode 100644 server/sanitize.ts create mode 100644 shared/supplierMail.ts diff --git a/Dockerfile b/Dockerfile index 3939f79..d202c00 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,7 +61,8 @@ RUN npx esbuild server/index.production.ts \ --external:express-rate-limit \ --external:memoizee \ --external:nanoid \ - --external:date-fns + --external:date-fns \ + --external:nodemailer # Production stage FROM node:20-alpine AS production diff --git a/client/src/lib/supplierMail.ts b/client/src/lib/supplierMail.ts deleted file mode 100644 index 7df5d97..0000000 --- a/client/src/lib/supplierMail.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { safeFormat } from "@/lib/dateUtils"; - -/** - * Génération des emails de relance fournisseur (rapprochement BL/Factures). - * Le lien mailto ouvre le client de messagerie par défaut du poste (Outlook) - * avec le destinataire, l'objet et le corps du message déjà préremplis. - */ - -export interface SupplierMailDelivery { - blNumber?: string | null; - blAmount?: string | number | null; - invoiceReference?: string | null; - scheduledDate?: string | Date | null; - deliveredDate?: string | Date | null; - supplier?: { - name?: string | null; - email?: string | null; - contact?: string | null; - } | null; - group?: { name?: string | null } | null; -} - -/** - * Objet du mail : identifie la livraison concernée. - */ -export function buildSupplierMailSubject(delivery: SupplierMailDelivery): string { - const deliveryDate = delivery.deliveredDate || delivery.scheduledDate; - const formattedDate = deliveryDate - ? safeFormat(deliveryDate, "dd/MM/yyyy", { defaultValue: "" }) - : ""; - - const reference = delivery.blNumber - ? `BL n° ${delivery.blNumber}` - : formattedDate - ? `Livraison du ${formattedDate}` - : "Livraison"; - - const parts = [ - "Demande de facture (PDF) ou BL (Excel)", - delivery.blNumber && formattedDate ? `${reference} du ${formattedDate}` : reference, - ]; - - if (delivery.group?.name) { - parts.push(delivery.group.name); - } - - return parts.join(" - "); -} - -/** - * Corps du mail : rappel des documents attendus + récapitulatif de la livraison. - * Seules les informations réellement renseignées sont listées. - */ -export function buildSupplierMailBody(delivery: SupplierMailDelivery): string { - const deliveryDate = delivery.deliveredDate || delivery.scheduledDate; - const formattedDate = deliveryDate - ? safeFormat(deliveryDate, "dd/MM/yyyy", { defaultValue: "" }) - : ""; - - const blAmount = - delivery.blAmount !== null && delivery.blAmount !== undefined && delivery.blAmount !== "" - ? parseFloat(String(delivery.blAmount)) - : null; - - const details: string[] = []; - if (delivery.supplier?.name) details.push(`- Fournisseur : ${delivery.supplier.name}`); - if (delivery.group?.name) details.push(`- Magasin : ${delivery.group.name}`); - if (formattedDate) details.push(`- Date de livraison : ${formattedDate}`); - if (delivery.blNumber) details.push(`- N° de BL : ${delivery.blNumber}`); - if (blAmount !== null && !isNaN(blAmount)) { - details.push(`- Montant du BL : ${blAmount.toFixed(2)} €`); - } - if (delivery.invoiceReference) { - details.push(`- Référence facture : ${delivery.invoiceReference}`); - } - - const lines = [ - "Bonjour,", - "", - "Dans le cadre du rapprochement de nos bons de livraison et de vos factures, nous vous remercions de bien vouloir nous transmettre :", - "", - "- la facture au format PDF, ou", - "- le bon de livraison au format Excel", - "", - "Livraison concernée :", - ...(details.length > 0 ? details : ["- (informations de livraison à préciser)"]), - "", - "Vous en remerciant par avance.", - "", - // Le corps s'arrête ici : l'enseigne, le magasin et le logo sont apportés - // par la signature configurée dans Outlook sur le poste. - "Cordialement,", - ]; - - return lines.join("\n"); -} - -/** - * Construit le lien mailto complet (destinataire + objet + corps préremplis). - */ -export function buildSupplierMailtoUrl( - delivery: SupplierMailDelivery, - supplierEmail: string -): string { - const subject = buildSupplierMailSubject(delivery); - // RFC 6068 : les sauts de ligne du corps doivent être encodés en CRLF (%0D%0A), - // sinon Outlook peut coller les lignes les unes aux autres. - const body = buildSupplierMailBody(delivery).replace(/\n/g, "\r\n"); - - return `mailto:${supplierEmail.trim()}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; -} - -/** - * Ouvre le client de messagerie par défaut (Outlook sur les postes du magasin). - */ -export function openMailClient(mailtoUrl: string): void { - const link = document.createElement("a"); - link.href = mailtoUrl; - link.style.display = "none"; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); -} diff --git a/client/src/pages/BLReconciliation.tsx b/client/src/pages/BLReconciliation.tsx index 995da70..a3d4646 100644 --- a/client/src/pages/BLReconciliation.tsx +++ b/client/src/pages/BLReconciliation.tsx @@ -19,7 +19,6 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import ReconciliationComments from "@/components/ReconciliationComments"; import ReconciliationModal from "@/components/modals/ReconciliationModal"; -import { buildSupplierMailtoUrl, openMailClient } from "@/lib/supplierMail"; export default function BLReconciliation() { const { user } = useAuthUnified(); @@ -68,6 +67,8 @@ export default function BLReconciliation() { // État pour le système de vérification de facture const [verificationResults, setVerificationResults] = useState>({}); const [verifyingDeliveries, setVerifyingDeliveries] = useState>(new Set()); + // Livraisons dont le mail fournisseur est en cours d'envoi + const [sendingMailDeliveries, setSendingMailDeliveries] = useState>(new Set()); // État pour le modal de commentaire const [showCommentModal, setShowCommentModal] = useState(false); @@ -676,8 +677,34 @@ export default function BLReconciliation() { return supplier?.email ? String(supplier.email).trim() : ''; }; - // Ouvre Outlook (client mail par défaut) avec un message prérempli - // demandant la facture au format PDF ou le BL au format Excel + // Envoi de la demande de facture (PDF) / BL (Excel) au fournisseur. + // Le mail part du serveur SMTP configuré sur la fiche du magasin. + const sendSupplierMailMutation = useMutation({ + mutationFn: async (delivery: any) => { + return await apiRequest(`/api/deliveries/${delivery.id}/send-supplier-mail`, 'POST'); + }, + onSuccess: (result: any) => { + toast({ + title: "Mail envoyé", + description: `Demande envoyée à ${result?.supplierName || 'le fournisseur'} (${result?.sentTo})`, + }); + }, + onError: (error: any) => { + toast({ + title: "Envoi impossible", + description: error?.message || "Le mail n'a pas pu être envoyé", + variant: "destructive", + }); + }, + onSettled: (_result, _error, delivery: any) => { + setSendingMailDeliveries(prev => { + const next = new Set(prev); + next.delete(delivery.id); + return next; + }); + }, + }); + const handleRequestDocumentsByEmail = (delivery: any) => { const supplierEmail = getSupplierEmail(delivery); @@ -690,8 +717,12 @@ export default function BLReconciliation() { return; } - const mailtoUrl = buildSupplierMailtoUrl(delivery, supplierEmail); - openMailClient(mailtoUrl); + // Envoi direct : on bloque la ligne le temps de la requête pour éviter + // qu'un double clic ne déclenche deux mails au même fournisseur. + if (sendingMailDeliveries.has(delivery.id)) return; + + setSendingMailDeliveries(prev => new Set(prev).add(delivery.id)); + sendSupplierMailMutation.mutate(delivery); }; const handleQuickValidate = async (delivery: any) => { @@ -1132,10 +1163,12 @@ export default function BLReconciliation() {
{(() => { const supplierEmail = getSupplierEmail(delivery); + const isSending = sendingMailDeliveries.has(delivery.id); return ( ); })()} @@ -1462,8 +1501,10 @@ export default function BLReconciliation() {
{(() => { const supplierEmail = getSupplierEmail(delivery); + const isSending = sendingMailDeliveries.has(delivery.id); return ( ); })()} diff --git a/client/src/pages/Groups.tsx b/client/src/pages/Groups.tsx index 8b80e95..76a8394 100644 --- a/client/src/pages/Groups.tsx +++ b/client/src/pages/Groups.tsx @@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { useAuthUnified } from "@/hooks/useAuthUnified"; import { useToast } from "@/hooks/use-toast"; @@ -33,6 +34,38 @@ const colorOptions = [ { value: '#00796B', label: 'Sarcelle' }, ]; +// Taille maximale du logo magasin : il est encodé en base64 dans la fiche +// et joint à chaque mail, un fichier lourd alourdirait tous les envois. +const MAX_LOGO_KB = 200; + +// Valeurs par défaut du formulaire magasin (création et réinitialisations) +const EMPTY_GROUP_FORM = { + name: "", + color: "#1976D2", + nocodbConfigId: "", + nocodbTableName: "", + invoiceColumnName: "", + nocodbBlColumnName: "", + nocodbAmountColumnName: "", + nocodbInvoiceAmountTTCColumnName: "", + nocodbSupplierColumnName: "", + nocodbDueDateColumnName: "", + webhookUrl: "", + // Coordonnées reprises dans la signature des mails fournisseurs + address: "", + phone: "", + logo: "", + // Configuration SMTP propre au magasin + smtpEnabled: false, + smtpHost: "", + smtpPort: "", + smtpSecure: false, + smtpUser: "", + smtpPassword: "", + smtpSenderEmail: "", + smtpSenderName: "", +}; + export default function Groups() { const { user } = useAuthUnified(); const { toast } = useToast(); @@ -42,19 +75,7 @@ export default function Groups() { const [showCreateModal, setShowCreateModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [selectedGroup, setSelectedGroup] = useState(null); - const [formData, setFormData] = useState({ - name: "", - color: "#1976D2", - nocodbConfigId: "", - nocodbTableName: "", - invoiceColumnName: "", - nocodbBlColumnName: "", - nocodbAmountColumnName: "", - nocodbInvoiceAmountTTCColumnName: "", - nocodbSupplierColumnName: "", - nocodbDueDateColumnName: "", - webhookUrl: "", - }); + const [formData, setFormData] = useState({ ...EMPTY_GROUP_FORM }); const { data: groups = [], isLoading } = useQuery({ queryKey: ['/api/groups'], @@ -97,19 +118,7 @@ export default function Groups() { }); queryClient.invalidateQueries({ queryKey: ['/api/groups'] }); setShowCreateModal(false); - setFormData({ - name: "", - color: "#1976D2", - nocodbConfigId: "", - nocodbTableName: "", - invoiceColumnName: "", - nocodbBlColumnName: "", - nocodbAmountColumnName: "", - nocodbInvoiceAmountTTCColumnName: "", - nocodbSupplierColumnName: "", - nocodbDueDateColumnName: "", - webhookUrl: "", - }); + setFormData({ ...EMPTY_GROUP_FORM }); }, onError: (error) => { if (isUnauthorizedError(error)) { @@ -143,19 +152,7 @@ export default function Groups() { queryClient.invalidateQueries({ queryKey: ['/api/groups'] }); setShowEditModal(false); setSelectedGroup(null); - setFormData({ - name: "", - color: "#1976D2", - nocodbConfigId: "", - nocodbTableName: "", - invoiceColumnName: "", - nocodbBlColumnName: "", - nocodbAmountColumnName: "", - nocodbInvoiceAmountTTCColumnName: "", - nocodbSupplierColumnName: "", - nocodbDueDateColumnName: "", - webhookUrl: "", - }); + setFormData({ ...EMPTY_GROUP_FORM }); }, onError: (error) => { if (isUnauthorizedError(error)) { @@ -177,6 +174,53 @@ export default function Groups() { }, }); + // Test de la connexion SMTP du magasin (aucun message envoyé) + const testSmtpMutation = useMutation({ + mutationFn: async (groupId: number) => { + return await apiRequest(`/api/groups/${groupId}/test-smtp`, 'POST'); + }, + onSuccess: (result: any) => { + toast({ + title: "Connexion réussie", + description: result?.message || "Le serveur SMTP répond correctement", + }); + }, + onError: (error: any) => { + toast({ + title: "Échec de la connexion", + description: error?.message || "Impossible de joindre le serveur SMTP", + variant: "destructive", + }); + }, + }); + + // Lecture du logo en data URI : stocké tel quel sur la fiche magasin + const handleLogoChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (file.size > MAX_LOGO_KB * 1024) { + toast({ + title: "Logo trop volumineux", + description: `Le fichier fait ${Math.round(file.size / 1024)} Ko. Maximum autorisé : ${MAX_LOGO_KB} Ko.`, + variant: "destructive", + }); + e.target.value = ''; + return; + } + + const reader = new FileReader(); + reader.onload = () => handleChange('logo', String(reader.result || '')); + reader.onerror = () => { + toast({ + title: "Lecture impossible", + description: "Le fichier n'a pas pu être lu", + variant: "destructive", + }); + }; + reader.readAsDataURL(file); + }; + const deleteMutation = useMutation({ mutationFn: async (id: number) => { await apiRequest(`/api/groups/${id}`, "DELETE"); @@ -225,19 +269,7 @@ export default function Groups() { }; const handleCreate = () => { - setFormData({ - name: "", - color: "#1976D2", - nocodbConfigId: "", - nocodbTableName: "", - invoiceColumnName: "", - nocodbBlColumnName: "", - nocodbAmountColumnName: "", - nocodbInvoiceAmountTTCColumnName: "", - nocodbSupplierColumnName: "", - nocodbDueDateColumnName: "", - webhookUrl: "", - }); + setFormData({ ...EMPTY_GROUP_FORM }); setShowCreateModal(true); }; @@ -255,6 +287,18 @@ export default function Groups() { nocodbSupplierColumnName: group.nocodbSupplierColumnName || "", nocodbDueDateColumnName: group.nocodbDueDateColumnName || "", webhookUrl: group.webhookUrl || "", + address: (group as any).address || "", + phone: (group as any).phone || "", + logo: (group as any).logo || "", + smtpEnabled: Boolean((group as any).smtpEnabled), + smtpHost: (group as any).smtpHost || "", + smtpPort: (group as any).smtpPort?.toString() || "", + smtpSecure: Boolean((group as any).smtpSecure), + smtpUser: (group as any).smtpUser || "", + // Jamais renvoyé par l'API : vide = mot de passe inchangé + smtpPassword: "", + smtpSenderEmail: (group as any).smtpSenderEmail || "", + smtpSenderName: (group as any).smtpSenderName || "", }); setShowEditModal(true); }; @@ -289,11 +333,17 @@ export default function Groups() { } // Prepare data with proper type conversion - const submitData = { + const submitData: any = { ...formData, nocodbConfigId: formData.nocodbConfigId ? parseInt(formData.nocodbConfigId) : null, + smtpPort: formData.smtpPort ? parseInt(formData.smtpPort) : null, }; + // Mot de passe laissé vide = on conserve celui déjà enregistré + if (!formData.smtpPassword) { + delete submitData.smtpPassword; + } + if (selectedGroup) { updateMutation.mutate(submitData); } else { @@ -301,7 +351,7 @@ export default function Groups() { } }; - const handleChange = (field: string, value: string) => { + const handleChange = (field: string, value: string | boolean) => { setFormData(prev => ({ ...prev, [field]: value })); }; @@ -683,27 +733,215 @@ export default function Groups() { )}
+ {/* Section Coordonnées du magasin (signature des mails) */} +
+
+
+

Coordonnées du magasin

+
+

+ Reprises dans la signature des mails envoyés aux fournisseurs. +

+ +
+ +