mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-12 01:37:40 +02:00
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
166 lines
4.7 KiB
TypeScript
166 lines
4.7 KiB
TypeScript
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();
|
|
}
|
|
}
|