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

+165
View File
@@ -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();
}
}