feat(securite): encrypt SMTP passwords and NocoDB tokens at rest, log supplier mails

Encryption (server/crypto.ts):
- AES-256-GCM with a key derived from ENCRYPTION_KEY, falling back to
  SESSION_SECRET so existing deployments need no new configuration
- stored format enc:v1:<iv>:<tag>:<data>; encryption is idempotent and
  decryption passes legacy plaintext through unchanged, so nothing breaks
  mid-migration
- tampered data or a changed key raises an explicit error instead of
  returning garbage
- encrypt/decrypt is confined to the storage layer: group writes encrypt
  smtpPassword (decrypted only in emailService at connection time, never sent
  to the client), NocoDB config writes encrypt apiToken and reads decrypt it
  so the invoice verification and the admin page behave as before
- startup migration sweep encrypts secrets already stored in plaintext,
  idempotently; the active-config log line no longer prints the token

Mail history (supplier_mail_logs):
- every send attempt is recorded: delivery, store, supplier, recipient,
  subject, status sent/failed with error, message id, user id and name;
  a logging failure never fails the send itself
- GET /api/supplier-mail-logs restricted to the user's stores (admin may
  filter by store)
- on the reconciliation page the mail icon turns green once a request has
  been sent, with the date and sender in the tooltip; clicking again resends
- table created in init.sql, versioned migrations and the production
  startup migration, with delivery/group indexes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHdE9nEh8zHhQNGpCgrtYe
This commit is contained in:
Claude committed 2026-08-14 16:02:55 +00:00
1 parent 012024a293
commit 9de717387f
9 files changed
+433 -40

No files matched your search

+74 -14
View File
@@ -9,6 +9,7 @@ import {
verifySmtpConfig,
getMissingSmtpFields,
} from "./emailService";
import { buildSupplierMailSubject } from "@shared/supplierMail";
import { db, pool } from "./db";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
@@ -2369,22 +2370,53 @@ export async function registerRoutes(app: Express): Promise<Server> {
});
}
const result = await sendSupplierDocumentRequest(group as any, delivery as any, supplierEmail);
// Historisation de la tentative, succès comme échec — un échec de
// journalisation ne doit jamais faire échouer (ni annuler) l'envoi
const senderName = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
|| user.username || user.id;
const logAttempt = async (status: 'sent' | 'failed', extra: { messageId?: string; errorMessage?: string }) => {
try {
await storage.createSupplierMailLog({
deliveryId,
groupId: delivery.groupId,
supplierId: delivery.supplierId ?? null,
supplierName: delivery.supplier?.name || null,
sentTo: supplierEmail,
subject: buildSupplierMailSubject(delivery as any),
status,
errorMessage: extra.errorMessage || null,
messageId: extra.messageId || null,
sentBy: user.id,
sentByName: senderName,
});
} catch (logError) {
console.error('⚠️ Historisation du mail fournisseur impossible:', logError);
}
};
console.log('📧 Mail fournisseur envoyé:', {
deliveryId,
supplier: delivery.supplier?.name,
to: supplierEmail,
store: group.name,
messageId: result.messageId
});
try {
const result = await sendSupplierDocumentRequest(group as any, delivery as any, supplierEmail);
res.json({
success: true,
sentTo: supplierEmail,
supplierName: delivery.supplier?.name || null,
messageId: result.messageId
});
await logAttempt('sent', { messageId: result.messageId });
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 (sendError: any) {
await logAttempt('failed', { errorMessage: sendError?.message || 'Erreur inconnue' });
throw sendError;
}
} catch (error: any) {
console.error("Erreur envoi mail fournisseur:", error);
res.status(500).json({
@@ -2393,6 +2425,34 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// Historique des relances fournisseurs, restreint aux magasins de l'utilisateur
app.get('/api/supplier-mail-logs', 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 = req.query.deliveryId ? parseInt(req.query.deliveryId as string) : undefined;
let groupIds: number[] | undefined;
if (user.role !== 'admin') {
groupIds = user.userGroups?.map((ug: any) => ug.groupId) || [];
if (groupIds.length === 0) {
return res.json([]);
}
} else if (req.query.storeId) {
groupIds = [parseInt(req.query.storeId as string)];
}
const logs = await storage.getSupplierMailLogs(groupIds, deliveryId);
res.json(logs);
} catch (error) {
console.error("Erreur lecture historique mails fournisseurs:", error);
res.status(500).json({ message: "Failed to fetch supplier mail logs" });
}
});
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);