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
@@ -61,6 +61,19 @@ export const groups = pgTable("groups", {
|
||||
nocodbSupplierColumnName: varchar("nocodb_supplier_column_name"), // Nom de la colonne fournisseur dans leur table
|
||||
nocodbDueDateColumnName: varchar("nocodb_due_date_column_name"), // Nom de la colonne date d'échéance dans leur table
|
||||
webhookUrl: varchar("webhook_url", { length: 500 }), // URL de webhook pour notifications par magasin
|
||||
// Coordonnées du magasin, reprises dans la signature des mails fournisseurs
|
||||
address: text("address"), // Adresse postale
|
||||
phone: varchar("phone", { length: 50 }), // Téléphone
|
||||
logo: text("logo"), // Logo en data URI (data:image/png;base64,...), intégré aux mails
|
||||
// Configuration SMTP propre au magasin (envoi des mails fournisseurs)
|
||||
smtpEnabled: boolean("smtp_enabled").default(false),
|
||||
smtpHost: varchar("smtp_host", { length: 255 }),
|
||||
smtpPort: integer("smtp_port"),
|
||||
smtpSecure: boolean("smtp_secure").default(false), // true = SSL/TLS direct (465), false = STARTTLS
|
||||
smtpUser: varchar("smtp_user", { length: 255 }),
|
||||
smtpPassword: varchar("smtp_password", { length: 255 }), // jamais renvoyé au client
|
||||
smtpSenderEmail: varchar("smtp_sender_email", { length: 255 }), // adresse expéditeur "De :"
|
||||
smtpSenderName: varchar("smtp_sender_name", { length: 255 }), // nom affiché, par défaut le nom du magasin
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
updatedAt: timestamp("updated_at").defaultNow(),
|
||||
});
|
||||
@@ -554,6 +567,17 @@ export const insertGroupSchema = createInsertSchema(groups).omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
}).extend({
|
||||
// Champs optionnels : le formulaire envoie "" quand ils ne sont pas renseignés
|
||||
address: z.string().optional().nullable(),
|
||||
phone: z.string().optional().nullable(),
|
||||
logo: z.string().optional().nullable(),
|
||||
smtpHost: z.string().optional().nullable(),
|
||||
smtpPort: z.coerce.number().int().min(1).max(65535).optional().nullable(),
|
||||
smtpUser: z.string().optional().nullable(),
|
||||
smtpPassword: z.string().optional().nullable(),
|
||||
smtpSenderEmail: z.union([z.string().email(), z.literal("")]).optional().nullable(),
|
||||
smtpSenderName: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export const insertSupplierSchema = createInsertSchema(suppliers).omit({
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Contenu des mails de relance fournisseur (rapprochement BL/Factures).
|
||||
* Partagé client/serveur : le serveur construit et envoie le mail via le SMTP
|
||||
* du magasin, le client peut réutiliser l'objet pour l'affichage.
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Coordonnées du magasin reprises dans la signature. */
|
||||
export interface StoreSignature {
|
||||
name?: string | null;
|
||||
address?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
/** Formatage jj/mm/aaaa, tolérant aux valeurs absentes ou invalides. */
|
||||
function formatDate(date: string | Date | null | undefined): string {
|
||||
if (!date) return "";
|
||||
try {
|
||||
const dateObj = typeof date === "string" ? new Date(date) : date;
|
||||
if (isNaN(dateObj.getTime())) return "";
|
||||
|
||||
const day = String(dateObj.getDate()).padStart(2, "0");
|
||||
const month = String(dateObj.getMonth() + 1).padStart(2, "0");
|
||||
return `${day}/${month}/${dateObj.getFullYear()}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Objet du mail : identifie la livraison concernée.
|
||||
*/
|
||||
export function buildSupplierMailSubject(delivery: SupplierMailDelivery): string {
|
||||
const formattedDate = formatDate(delivery.deliveredDate || delivery.scheduledDate);
|
||||
|
||||
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(" - ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Récapitulatif de la livraison : seules les informations renseignées sont listées.
|
||||
*/
|
||||
function buildDeliveryDetails(delivery: SupplierMailDelivery): string[] {
|
||||
const formattedDate = formatDate(delivery.deliveredDate || delivery.scheduledDate);
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
return details.length > 0 ? details : ["(informations de livraison à préciser)"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Version texte du mail (repli pour les clients sans HTML).
|
||||
*/
|
||||
export function buildSupplierMailText(
|
||||
delivery: SupplierMailDelivery,
|
||||
store?: StoreSignature | null
|
||||
): string {
|
||||
const details = buildDeliveryDetails(delivery).map(line => `- ${line}`);
|
||||
|
||||
const signature = [store?.name, store?.address, store?.phone ? `Tél. ${store.phone}` : null, store?.email]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
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,
|
||||
"",
|
||||
"Vous en remerciant par avance.",
|
||||
"",
|
||||
"Cordialement,",
|
||||
...(signature ? [signature] : []),
|
||||
];
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Échappement HTML des valeurs injectées dans le corps du mail. */
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* Version HTML du mail, avec la signature du magasin et son logo.
|
||||
* Le logo est référencé par `cid:logo` : il est joint au message par
|
||||
* l'expéditeur, ce qui évite les images distantes bloquées par Outlook.
|
||||
*/
|
||||
export function buildSupplierMailHtml(
|
||||
delivery: SupplierMailDelivery,
|
||||
store?: StoreSignature | null,
|
||||
options: { hasLogo?: boolean } = {}
|
||||
): string {
|
||||
const details = buildDeliveryDetails(delivery)
|
||||
.map(line => `<li>${escapeHtml(line)}</li>`)
|
||||
.join("");
|
||||
|
||||
const signatureLines = [
|
||||
store?.name ? `<strong>${escapeHtml(store.name)}</strong>` : null,
|
||||
store?.address ? escapeHtml(store.address).replace(/\n/g, "<br>") : null,
|
||||
store?.phone ? `Tél. ${escapeHtml(store.phone)}` : null,
|
||||
store?.email
|
||||
? `<a href="mailto:${escapeHtml(store.email)}">${escapeHtml(store.email)}</a>`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
const logoBlock = options.hasLogo
|
||||
? '<p style="margin:0 0 8px 0;"><img src="cid:logo" alt="" style="max-width:200px;height:auto;border:0;"></p>'
|
||||
: "";
|
||||
|
||||
const signatureBlock = signatureLines.length > 0 || logoBlock
|
||||
? `${logoBlock}<p style="margin:0;color:#444;font-size:13px;line-height:1.5;">${signatureLines.join("<br>")}</p>`
|
||||
: "";
|
||||
|
||||
return `<div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#222;line-height:1.5;">
|
||||
<p>Bonjour,</p>
|
||||
<p>Dans le cadre du rapprochement de nos bons de livraison et de vos factures, nous vous remercions de bien vouloir nous transmettre :</p>
|
||||
<ul>
|
||||
<li>la facture au format PDF, ou</li>
|
||||
<li>le bon de livraison au format Excel</li>
|
||||
</ul>
|
||||
<p style="margin-bottom:4px;">Livraison concernée :</p>
|
||||
<ul>${details}</ul>
|
||||
<p>Vous en remerciant par avance.</p>
|
||||
<p style="margin-bottom:12px;">Cordialement,</p>
|
||||
${signatureBlock}
|
||||
</div>`;
|
||||
}
|
||||
Reference in new issue
Block a user