fix: migrer envoi email de SMTP/Nodemailer vers Microsoft Graph sendMail

- email.ts : remplace Nodemailer par fetch Graph (POST /users/{email}/sendMail)
  * getGraphEmailConfig() lit les credentials depuis app_settings
  * getAccessToken() : client_credentials flow
  * sendInvoiceEmail(senderEmail, senderName, toEmail, pdfPath, pdfFilename)
  * sendTestEmail(senderEmail, senderName) : envoie à soi-même
- invoices.ts : retire la vérification SMTP, passe user.email + user.name
- settings.ts : test email utilise désormais Graph (même endpoint /smtp/test)
This commit is contained in:
Claude
2026-05-05 15:02:04 +00:00
parent 77862650cf
commit 8b1f3f581e
3 changed files with 164 additions and 66 deletions
+3 -8
View File
@@ -306,14 +306,9 @@ router.post('/:id/send', wrap(async (req: AuthRequest, res: Response): Promise<v
if (!invoice) { res.status(404).json({ error: 'Facture introuvable' }); return; }
if (invoice.user_id !== req.user!.id) { res.status(403).json({ error: 'Accès refusé' }); return; }
const userResult = await db.query('SELECT * FROM users WHERE id=$1', [req.user!.id]);
const userResult = await db.query('SELECT id, name, email FROM users WHERE id=$1', [req.user!.id]);
const user = userResult.rows[0];
if (!user.smtp_host || !user.smtp_pass_enc) {
res.status(400).json({ error: 'SMTP non configuré. Allez dans Paramètres → Email.' });
return;
}
// ── Nommage du PDF ────────────────────────────────────────
const dateObj = new Date(invoice.invoice_date);
const dateStr = dateObj.toISOString().split('T')[0]; // AAAA-MM-JJ
@@ -332,8 +327,8 @@ router.post('/:id/send', wrap(async (req: AuthRequest, res: Response): Promise<v
await generateInvoicePdf(imagePaths, invoice.guests, pdfPath, user.name);
// ── Envoi email ───────────────────────────────────────────
await sendInvoiceEmail(user, invoice.company_email, pdfPath, pdfFilename);
// ── Envoi email via Microsoft Graph ──────────────────────
await sendInvoiceEmail(user.email, user.name, invoice.company_email, pdfPath, pdfFilename);
// ── SharePoint (non bloquant) ─────────────────────────────
let trackingAdded = false;
+5 -10
View File
@@ -71,22 +71,17 @@ router.put('/smtp', validate(smtpSchema), wrap(async (req: AuthRequest, res: Res
res.json({ success: true });
}));
/** POST /api/settings/smtp/test — Envoie un email de test */
/** POST /api/settings/smtp/test — Envoie un email de test via Microsoft Graph */
router.post('/smtp/test', wrap(async (req: AuthRequest, res: Response): Promise<void> => {
const userResult = await db.query('SELECT * FROM users WHERE id=$1', [req.user!.id]);
const userResult = await db.query('SELECT name, email FROM users WHERE id=$1', [req.user!.id]);
const user = userResult.rows[0];
if (!user.smtp_host || !user.smtp_pass_enc) {
res.status(400).json({ error: 'SMTP non configuré' });
return;
}
try {
const { sendTestEmail } = await import('../services/email');
await sendTestEmail(user);
res.json({ success: true, message: `Email de test envoyé à ${user.smtp_from_email}` });
await sendTestEmail(user.email, user.name);
res.json({ success: true, message: `Email de test envoyé à ${user.email}` });
} catch (err: any) {
res.status(500).json({ error: `Échec SMTP : ${err.message}` });
res.status(500).json({ error: `Échec envoi Graph : ${err.message}` });
}
}));
+148 -40
View File
@@ -1,66 +1,174 @@
import nodemailer from 'nodemailer';
import { db } from '../db';
import { decrypt } from '../crypto';
import fs from 'fs/promises';
export interface UserSmtpConfig {
smtp_host: string;
smtp_port: number;
smtp_secure: boolean;
smtp_user: string;
smtp_pass_enc: string;
smtp_from_name: string;
smtp_from_email: string;
// ─── Types ───────────────────────────────────────────────────
interface GraphEmailConfig {
tenantId: string;
clientId: string;
clientSecret: string;
}
function createTransporter(user: UserSmtpConfig) {
return nodemailer.createTransport({
host: user.smtp_host,
port: user.smtp_port,
secure: user.smtp_secure,
auth: {
user: user.smtp_user,
pass: decrypt(user.smtp_pass_enc),
},
});
// ─── Helpers internes ────────────────────────────────────────
async function getGraphEmailConfig(): Promise<GraphEmailConfig> {
const result = await db.query(
`SELECT key, value FROM app_settings
WHERE key IN ('graph_tenant_id', 'graph_client_id', 'graph_client_secret_enc')`
);
const cfg: Record<string, string> = {};
for (const row of result.rows) cfg[row.key] = row.value;
if (!cfg.graph_tenant_id || !cfg.graph_client_id || !cfg.graph_client_secret_enc) {
throw new Error(
'Microsoft Graph non configuré. Allez dans Paramètres → Microsoft 365 et renseignez le Tenant ID, Client ID et Client Secret.'
);
}
return {
tenantId: cfg.graph_tenant_id,
clientId: cfg.graph_client_id,
clientSecret: decrypt(cfg.graph_client_secret_enc),
};
}
async function getAccessToken(cfg: GraphEmailConfig): Promise<string> {
const response = await fetch(
`https://login.microsoftonline.com/${cfg.tenantId}/oauth2/v2.0/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: cfg.clientId,
client_secret: cfg.clientSecret,
scope: 'https://graph.microsoft.com/.default',
}),
}
);
if (!response.ok) {
const body = await response.text();
throw new Error(`Obtention token Graph échouée (${response.status}) : ${body.slice(0, 300)}`);
}
const data = (await response.json()) as { access_token: string };
return data.access_token;
}
// ─── API publique ────────────────────────────────────────────
/**
* Envoie la facture par email.
* Expéditeur = compte SMTP de l'utilisateur connecté.
* Destinataire = email de la société à rembourser.
* Envoie la facture par email via Microsoft Graph (sendMail).
*
* Prérequis Azure :
* - Permission d'application : Mail.Send
* - L'expéditeur (senderEmail) doit être une boîte Exchange/M365 du tenant.
*
* @param senderEmail Email de l'expéditeur (user.email en base)
* @param senderName Nom affiché de l'expéditeur (user.name)
* @param toEmail Destinataire (company.email)
* @param pdfAbsPath Chemin absolu vers le PDF généré
* @param pdfFilename Nom du fichier à afficher dans la pièce jointe
*/
export async function sendInvoiceEmail(
user: UserSmtpConfig,
senderEmail: string,
senderName: string,
toEmail: string,
pdfAbsPath: string,
pdfFilename: string
): Promise<void> {
const transporter = createTransporter(user);
const cfg = await getGraphEmailConfig();
const token = await getAccessToken(cfg);
await transporter.sendMail({
from: `"${user.smtp_from_name}" <${user.smtp_from_email}>`,
to: toEmail,
// Lecture du PDF et encodage Base64
const pdfBuffer = await fs.readFile(pdfAbsPath);
const pdfBase64 = pdfBuffer.toString('base64');
const payload = {
message: {
subject: 'Facture à rembourser',
text: '',
body: {
contentType: 'Text',
content: '',
},
from: {
emailAddress: { name: senderName, address: senderEmail },
},
toRecipients: [
{ emailAddress: { address: toEmail } },
],
attachments: [
{
filename: pdfFilename,
path: pdfAbsPath,
'@odata.type': '#microsoft.graph.fileAttachment',
name: pdfFilename,
contentType: 'application/pdf',
contentBytes: pdfBase64,
},
],
});
},
saveToSentItems: true,
};
const resp = await fetch(
`https://graph.microsoft.com/v1.0/users/${encodeURIComponent(senderEmail)}/sendMail`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
}
);
if (!resp.ok) {
const text = await resp.text();
throw new Error(`Graph sendMail échoué (${resp.status}) : ${text.slice(0, 400)}`);
}
// 202 Accepted — Graph ne retourne pas de corps
}
/**
* Email de test pour vérifier la config SMTP de l'utilisateur.
* Email de test (s'envoie à soi-même) via Microsoft Graph.
* Utilisé par POST /api/settings/graph/test-email
*/
export async function sendTestEmail(user: UserSmtpConfig): Promise<void> {
const transporter = createTransporter(user);
export async function sendTestEmail(
senderEmail: string,
senderName: string
): Promise<void> {
const cfg = await getGraphEmailConfig();
const token = await getAccessToken(cfg);
await transporter.sendMail({
from: `"${user.smtp_from_name}" <${user.smtp_from_email}>`,
to: user.smtp_from_email,
subject: '[NotesFrais] Test de configuration SMTP',
text: 'Votre configuration SMTP fonctionne correctement ✓',
});
const payload = {
message: {
subject: '[NotesFrais] Test de configuration email',
body: {
contentType: 'Text',
content: `Bonjour ${senderName},\n\nVotre configuration Microsoft Graph fonctionne correctement ✓\n\n— NotesFrais`,
},
toRecipients: [
{ emailAddress: { address: senderEmail } },
],
},
saveToSentItems: false,
};
const resp = await fetch(
`https://graph.microsoft.com/v1.0/users/${encodeURIComponent(senderEmail)}/sendMail`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
}
);
if (!resp.ok) {
const text = await resp.text();
throw new Error(`Graph sendMail test échoué (${resp.status}) : ${text.slice(0, 400)}`);
}
}