mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-11 17:27:31 +02:00
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:
14 files changed
+994
-209
No files matched your search
@@ -0,0 +1,165 @@
|
||||
import nodemailer, { type Transporter } from 'nodemailer';
|
||||
import {
|
||||
buildSupplierMailSubject,
|
||||
buildSupplierMailText,
|
||||
buildSupplierMailHtml,
|
||||
type SupplierMailDelivery,
|
||||
} from '@shared/supplierMail';
|
||||
|
||||
/**
|
||||
* Envoi des mails fournisseurs via la configuration SMTP propre à chaque magasin.
|
||||
* Chaque magasin renseigne son serveur, ses identifiants et son adresse
|
||||
* d'expédition sur sa fiche : aucun compte global n'est utilisé.
|
||||
*/
|
||||
|
||||
export interface StoreSmtpConfig {
|
||||
id?: number;
|
||||
name?: string | null;
|
||||
address?: string | null;
|
||||
phone?: string | null;
|
||||
logo?: string | null;
|
||||
smtpEnabled?: boolean | null;
|
||||
smtpHost?: string | null;
|
||||
smtpPort?: number | null;
|
||||
smtpSecure?: boolean | null;
|
||||
smtpUser?: string | null;
|
||||
smtpPassword?: string | null;
|
||||
smtpSenderEmail?: string | null;
|
||||
smtpSenderName?: string | null;
|
||||
}
|
||||
|
||||
export class SmtpConfigError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SmtpConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie que le magasin dispose d'une configuration exploitable.
|
||||
* Renvoie la liste des champs manquants plutôt qu'un simple booléen,
|
||||
* pour pouvoir dire à l'utilisateur ce qu'il reste à renseigner.
|
||||
*/
|
||||
export function getMissingSmtpFields(group: StoreSmtpConfig | null | undefined): string[] {
|
||||
if (!group) return ['magasin'];
|
||||
|
||||
const missing: string[] = [];
|
||||
if (!group.smtpHost?.trim()) missing.push('serveur SMTP');
|
||||
if (!group.smtpPort) missing.push('port SMTP');
|
||||
if (!group.smtpSenderEmail?.trim()) missing.push('adresse expéditeur');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le transporteur nodemailer d'un magasin.
|
||||
* L'authentification est optionnelle : certains relais internes acceptent
|
||||
* les envois sans identifiants.
|
||||
*/
|
||||
export function createTransporter(group: StoreSmtpConfig): Transporter {
|
||||
const missing = getMissingSmtpFields(group);
|
||||
if (missing.length > 0) {
|
||||
throw new SmtpConfigError(`Configuration SMTP incomplète : ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
const hasAuth = Boolean(group.smtpUser?.trim() && group.smtpPassword);
|
||||
|
||||
return nodemailer.createTransport({
|
||||
host: group.smtpHost!.trim(),
|
||||
port: Number(group.smtpPort),
|
||||
secure: Boolean(group.smtpSecure),
|
||||
auth: hasAuth
|
||||
? { user: group.smtpUser!.trim(), pass: group.smtpPassword! }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** Adresse "De :" du magasin, avec son nom affiché. */
|
||||
function buildFromAddress(group: StoreSmtpConfig): string {
|
||||
const senderEmail = group.smtpSenderEmail!.trim();
|
||||
const senderName = group.smtpSenderName?.trim() || group.name?.trim();
|
||||
return senderName ? `"${senderName}" <${senderEmail}>` : senderEmail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait le contenu binaire d'un logo stocké en data URI.
|
||||
* Renvoie null si le logo est absent ou dans un format inattendu.
|
||||
*/
|
||||
function parseLogoDataUri(logo: string | null | undefined) {
|
||||
if (!logo) return null;
|
||||
|
||||
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/.exec(logo.trim());
|
||||
if (!match) return null;
|
||||
|
||||
const [, mimeType, base64] = match;
|
||||
const extension = mimeType.split('/')[1]?.split('+')[0] || 'png';
|
||||
|
||||
try {
|
||||
return {
|
||||
content: Buffer.from(base64, 'base64'),
|
||||
contentType: mimeType,
|
||||
filename: `logo.${extension}`,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Teste la configuration SMTP d'un magasin sans envoyer de message.
|
||||
*/
|
||||
export async function verifySmtpConfig(group: StoreSmtpConfig): Promise<void> {
|
||||
const transporter = createTransporter(group);
|
||||
try {
|
||||
await transporter.verify();
|
||||
} finally {
|
||||
transporter.close();
|
||||
}
|
||||
}
|
||||
|
||||
export interface SendSupplierMailResult {
|
||||
messageId: string;
|
||||
accepted: string[];
|
||||
rejected: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie au fournisseur la demande de facture PDF / BL Excel pour une livraison.
|
||||
*/
|
||||
export async function sendSupplierDocumentRequest(
|
||||
group: StoreSmtpConfig,
|
||||
delivery: SupplierMailDelivery,
|
||||
supplierEmail: string
|
||||
): Promise<SendSupplierMailResult> {
|
||||
const transporter = createTransporter(group);
|
||||
|
||||
try {
|
||||
const store = {
|
||||
name: group.name,
|
||||
address: group.address,
|
||||
phone: group.phone,
|
||||
email: group.smtpSenderEmail,
|
||||
};
|
||||
|
||||
const logo = parseLogoDataUri(group.logo);
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: buildFromAddress(group),
|
||||
to: supplierEmail.trim(),
|
||||
replyTo: group.smtpSenderEmail!.trim(),
|
||||
subject: buildSupplierMailSubject(delivery),
|
||||
text: buildSupplierMailText(delivery, store),
|
||||
html: buildSupplierMailHtml(delivery, store, { hasLogo: Boolean(logo) }),
|
||||
attachments: logo
|
||||
? [{ ...logo, cid: 'logo', contentDisposition: 'inline' as const }]
|
||||
: [],
|
||||
});
|
||||
|
||||
return {
|
||||
messageId: info.messageId,
|
||||
accepted: (info.accepted || []).map(String),
|
||||
rejected: (info.rejected || []).map(String),
|
||||
};
|
||||
} finally {
|
||||
transporter.close();
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,24 @@ export async function runProductionMigrations() {
|
||||
} else {
|
||||
console.log('✅ MIGRATION: Priority column already exists, skipping migration');
|
||||
}
|
||||
|
||||
|
||||
// Coordonnées magasin + configuration SMTP par magasin (mails fournisseurs)
|
||||
console.log('🔄 MIGRATION: Ensuring store contact and SMTP columns on groups...');
|
||||
await client.query(`
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS address TEXT;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS phone VARCHAR(50);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS logo TEXT;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_enabled BOOLEAN DEFAULT false;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_host VARCHAR(255);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_port INTEGER;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_secure BOOLEAN DEFAULT false;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_user VARCHAR(255);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_password VARCHAR(255);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_sender_email VARCHAR(255);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_sender_name VARCHAR(255);
|
||||
`);
|
||||
console.log('✅ MIGRATION: Store contact and SMTP columns are present on groups');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ MIGRATION ERROR: Failed to run SAV production migrations:', error);
|
||||
console.error('❌ MIGRATION ERROR: Error details:', {
|
||||
|
||||
+19
-1
@@ -60,12 +60,30 @@ export async function runMigrations() {
|
||||
);
|
||||
|
||||
INSERT INTO webhook_bap_config (name, webhook_url, description, is_active)
|
||||
SELECT
|
||||
SELECT
|
||||
'Configuration BAP',
|
||||
'https://workflow.ffnancy.fr/webhook/a3d03176-b72f-412d-8fb9-f920b9fbab4d',
|
||||
'Configuration par défaut pour envoi des fichiers BAP vers n8n',
|
||||
true
|
||||
WHERE NOT EXISTS (SELECT 1 FROM webhook_bap_config);`
|
||||
},
|
||||
{
|
||||
filename: '20260814000000_add_store_contact_and_smtp_to_groups.sql',
|
||||
content: `
|
||||
-- Coordonnées du magasin (signature des mails fournisseurs)
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS address TEXT;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS phone VARCHAR(50);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS logo TEXT;
|
||||
-- Configuration SMTP propre à chaque magasin
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_enabled BOOLEAN DEFAULT false;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_host VARCHAR(255);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_port INTEGER;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_secure BOOLEAN DEFAULT false;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_user VARCHAR(255);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_password VARCHAR(255);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_sender_email VARCHAR(255);
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_sender_name VARCHAR(255);
|
||||
`
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Nettoyage des données sensibles avant envoi au client.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Retire récursivement le mot de passe SMTP des réponses API et le remplace par
|
||||
* un indicateur de présence (smtpPasswordSet). Traverse les objets et tableaux
|
||||
* imbriqués car les magasins apparaissent sous plusieurs formes : liste de
|
||||
* groupes, champ "group" d'une livraison ou d'une commande, relations
|
||||
* utilisateur... Filtrer chaque requête serait fragile, on nettoie donc une
|
||||
* seule fois à la sortie.
|
||||
*/
|
||||
export function stripSmtpPassword(value: any, depth = 0, seen = new WeakSet()): any {
|
||||
if (depth > 8 || value === null || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
// Les structures cycliques sont renvoyées telles quelles plutôt que de
|
||||
// faire boucler la récursion
|
||||
if (seen.has(value)) {
|
||||
return value;
|
||||
}
|
||||
seen.add(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => stripSmtpPassword(item, depth + 1, seen));
|
||||
}
|
||||
|
||||
// Ne pas dénaturer les types non sérialisables en objets simples
|
||||
if (value instanceof Date || Buffer.isBuffer(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const result: Record<string, any> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (key === 'smtpPassword' || key === 'smtp_password') {
|
||||
result.smtpPasswordSet = Boolean(entry);
|
||||
continue;
|
||||
}
|
||||
result[key] = stripSmtpPassword(entry, depth + 1, seen);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Reference in new issue
Block a user