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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHdE9nEh8zHhQNGpCgrtYe
This commit is contained in:
Claude committed 2026-08-14 15:25:00 +00:00
1 parent 02645c34a5
commit 012024a293
14 files changed
+994 -209

No files matched your search

+139
View File
@@ -3,6 +3,12 @@ import { createServer, type Server } from "http";
import { storage } from "./storage";
import { setupLocalAuth, requireAuth } from "./localAuth";
import { requireModulePermission, requireAdmin, requirePermission } from "./permissions";
import { stripSmtpPassword } from "./sanitize";
import {
sendSupplierDocumentRequest,
verifySmtpConfig,
getMissingSmtpFields,
} from "./emailService";
import { db, pool } from "./db";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
@@ -11,6 +17,7 @@ const require = createRequire(import.meta.url);
console.log('🔍 Using development storage and authentication');
// Fonction de normalisation des dates pour gérer différents formats de NocoDB
function normalizeDateString(dateString: string | null | undefined): string | null {
if (!dateString || typeof dateString !== 'string') return null;
@@ -139,6 +146,19 @@ export async function registerRoutes(app: Express): Promise<Server> {
const environment = process.env.NODE_ENV || 'development';
console.log('🌍 Environment detected:', environment);
// Le mot de passe SMTP des magasins ne doit jamais sortir du serveur.
// Les objets "group" sont joints à de nombreuses réponses (livraisons,
// commandes, utilisateurs...) : plutôt que de filtrer chaque requête, on
// nettoie une seule fois à la sortie. Le client reçoit à la place un booléen
// smtpPasswordSet lui indiquant si un mot de passe est enregistré.
app.use('/api', (req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (body: any) => originalJson(stripSmtpPassword(body));
next();
});
// Health check endpoint for Docker
app.get('/api/health', (req, res) => {
res.status(200).json({
@@ -1042,6 +1062,13 @@ export async function registerRoutes(app: Express): Promise<Server> {
const id = parseInt(req.params.id);
const data = insertGroupSchema.partial().parse(req.body);
// Le mot de passe SMTP n'est jamais renvoyé au client : un champ vide
// signifie "inchangé", pas "effacer". On ne l'écrase que s'il est fourni.
if (!data.smtpPassword) {
delete (data as any).smtpPassword;
}
const group = await storage.updateGroup(id, data);
res.json(group);
} catch (error: any) {
@@ -1050,6 +1077,39 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// Test de la configuration SMTP d'un magasin (aucun message envoyé)
app.post('/api/groups/:id/test-smtp', isAuthenticated, async (req: any, res) => {
try {
const user = await storage.getUser(req.user.claims ? req.user.claims.sub : req.user.id);
if (!user || (user.role !== 'admin' && user.role !== 'manager')) {
return res.status(403).json({ message: "Insufficient permissions" });
}
const id = parseInt(req.params.id);
const group = await storage.getGroup(id);
if (!group) {
return res.status(404).json({ message: "Magasin introuvable" });
}
const missing = getMissingSmtpFields(group as any);
if (missing.length > 0) {
return res.status(400).json({
success: false,
message: `Configuration incomplète : ${missing.join(', ')}`
});
}
await verifySmtpConfig(group as any);
res.json({ success: true, message: "Connexion au serveur SMTP réussie" });
} catch (error: any) {
console.error("Erreur test SMTP:", error);
res.status(400).json({
success: false,
message: error?.message || "Impossible de joindre le serveur SMTP"
});
}
});
app.delete('/api/groups/:id', isAuthenticated, async (req: any, res) => {
try {
const user = await storage.getUser(req.user.claims ? req.user.claims.sub : req.user.id);
@@ -2254,6 +2314,85 @@ export async function registerRoutes(app: Express): Promise<Server> {
});
// Route de vérification de facture NocoDB
// Envoi au fournisseur de la demande de facture (PDF) ou de BL (Excel)
// via le serveur SMTP configuré sur la fiche du magasin de la livraison
app.post('/api/deliveries/:id/send-supplier-mail', isAuthenticated, async (req: any, res) => {
try {
const user = await storage.getUserWithGroups(req.user.claims ? req.user.claims.sub : req.user.id);
if (!user) {
return res.status(404).json({ message: "User not found" });
}
const deliveryId = parseInt(req.params.id);
const delivery = await storage.getDelivery(deliveryId);
if (!delivery) {
return res.status(404).json({ message: "Livraison introuvable" });
}
if (!hasPermission(user.role, 'deliveries', 'view')) {
return res.status(403).json({ message: "Insufficient permissions" });
}
// Hors admin, l'utilisateur doit appartenir au magasin de la livraison
if (user.role !== 'admin') {
const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || [];
if (!userGroupIds.includes(delivery.groupId)) {
return res.status(403).json({ message: "Access denied to this group" });
}
}
// Adresse du fournisseur : celle enregistrée sur sa fiche
const supplierEmail = delivery.supplier?.email?.trim();
if (!supplierEmail) {
return res.status(400).json({
message: `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}`
});
}
// Configuration SMTP du magasin (mot de passe inclus : usage serveur uniquement)
const group = await storage.getGroup(delivery.groupId);
if (!group) {
return res.status(404).json({ message: "Magasin de la livraison introuvable" });
}
if (!(group as any).smtpEnabled) {
return res.status(400).json({
message: `L'envoi de mails n'est pas activé pour le magasin ${group.name}. Renseignez la configuration SMTP sur sa fiche.`
});
}
const missing = getMissingSmtpFields(group as any);
if (missing.length > 0) {
return res.status(400).json({
message: `Configuration SMTP incomplète pour ${group.name} : ${missing.join(', ')}`
});
}
const result = await sendSupplierDocumentRequest(group as any, delivery as any, supplierEmail);
console.log('📧 Mail fournisseur envoyé:', {
deliveryId,
supplier: delivery.supplier?.name,
to: supplierEmail,
store: group.name,
messageId: result.messageId
});
res.json({
success: true,
sentTo: supplierEmail,
supplierName: delivery.supplier?.name || null,
messageId: result.messageId
});
} catch (error: any) {
console.error("Erreur envoi mail fournisseur:", error);
res.status(500).json({
message: error?.message || "Impossible d'envoyer le mail au fournisseur"
});
}
});
app.post('/api/deliveries/:id/verify-invoice', isAuthenticated, async (req: any, res) => {
try {
const user = await storage.getUserWithGroups(req.user.claims ? req.user.claims.sub : req.user.id);