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:
@@ -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) { res.status(404).json({ error: 'Facture introuvable' }); return; }
|
||||||
if (invoice.user_id !== req.user!.id) { res.status(403).json({ error: 'Accès refusé' }); 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];
|
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 ────────────────────────────────────────
|
// ── Nommage du PDF ────────────────────────────────────────
|
||||||
const dateObj = new Date(invoice.invoice_date);
|
const dateObj = new Date(invoice.invoice_date);
|
||||||
const dateStr = dateObj.toISOString().split('T')[0]; // AAAA-MM-JJ
|
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);
|
await generateInvoicePdf(imagePaths, invoice.guests, pdfPath, user.name);
|
||||||
|
|
||||||
// ── Envoi email ───────────────────────────────────────────
|
// ── Envoi email via Microsoft Graph ──────────────────────
|
||||||
await sendInvoiceEmail(user, invoice.company_email, pdfPath, pdfFilename);
|
await sendInvoiceEmail(user.email, user.name, invoice.company_email, pdfPath, pdfFilename);
|
||||||
|
|
||||||
// ── SharePoint (non bloquant) ─────────────────────────────
|
// ── SharePoint (non bloquant) ─────────────────────────────
|
||||||
let trackingAdded = false;
|
let trackingAdded = false;
|
||||||
|
|||||||
@@ -71,22 +71,17 @@ router.put('/smtp', validate(smtpSchema), wrap(async (req: AuthRequest, res: Res
|
|||||||
res.json({ success: true });
|
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> => {
|
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];
|
const user = userResult.rows[0];
|
||||||
|
|
||||||
if (!user.smtp_host || !user.smtp_pass_enc) {
|
|
||||||
res.status(400).json({ error: 'SMTP non configuré' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { sendTestEmail } = await import('../services/email');
|
const { sendTestEmail } = await import('../services/email');
|
||||||
await sendTestEmail(user);
|
await sendTestEmail(user.email, user.name);
|
||||||
res.json({ success: true, message: `Email de test envoyé à ${user.smtp_from_email}` });
|
res.json({ success: true, message: `Email de test envoyé à ${user.email}` });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: `Échec SMTP : ${err.message}` });
|
res.status(500).json({ error: `Échec envoi Graph : ${err.message}` });
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
+148
-40
@@ -1,66 +1,174 @@
|
|||||||
import nodemailer from 'nodemailer';
|
import { db } from '../db';
|
||||||
import { decrypt } from '../crypto';
|
import { decrypt } from '../crypto';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
|
||||||
export interface UserSmtpConfig {
|
// ─── Types ───────────────────────────────────────────────────
|
||||||
smtp_host: string;
|
|
||||||
smtp_port: number;
|
interface GraphEmailConfig {
|
||||||
smtp_secure: boolean;
|
tenantId: string;
|
||||||
smtp_user: string;
|
clientId: string;
|
||||||
smtp_pass_enc: string;
|
clientSecret: string;
|
||||||
smtp_from_name: string;
|
|
||||||
smtp_from_email: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTransporter(user: UserSmtpConfig) {
|
// ─── Helpers internes ────────────────────────────────────────
|
||||||
return nodemailer.createTransport({
|
|
||||||
host: user.smtp_host,
|
async function getGraphEmailConfig(): Promise<GraphEmailConfig> {
|
||||||
port: user.smtp_port,
|
const result = await db.query(
|
||||||
secure: user.smtp_secure,
|
`SELECT key, value FROM app_settings
|
||||||
auth: {
|
WHERE key IN ('graph_tenant_id', 'graph_client_id', 'graph_client_secret_enc')`
|
||||||
user: user.smtp_user,
|
);
|
||||||
pass: decrypt(user.smtp_pass_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.
|
* Envoie la facture par email via Microsoft Graph (sendMail).
|
||||||
* Expéditeur = compte SMTP de l'utilisateur connecté.
|
*
|
||||||
* Destinataire = email de la société à rembourser.
|
* 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(
|
export async function sendInvoiceEmail(
|
||||||
user: UserSmtpConfig,
|
senderEmail: string,
|
||||||
|
senderName: string,
|
||||||
toEmail: string,
|
toEmail: string,
|
||||||
pdfAbsPath: string,
|
pdfAbsPath: string,
|
||||||
pdfFilename: string
|
pdfFilename: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const transporter = createTransporter(user);
|
const cfg = await getGraphEmailConfig();
|
||||||
|
const token = await getAccessToken(cfg);
|
||||||
|
|
||||||
await transporter.sendMail({
|
// Lecture du PDF et encodage Base64
|
||||||
from: `"${user.smtp_from_name}" <${user.smtp_from_email}>`,
|
const pdfBuffer = await fs.readFile(pdfAbsPath);
|
||||||
to: toEmail,
|
const pdfBase64 = pdfBuffer.toString('base64');
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
message: {
|
||||||
subject: 'Facture à rembourser',
|
subject: 'Facture à rembourser',
|
||||||
text: '',
|
body: {
|
||||||
|
contentType: 'Text',
|
||||||
|
content: '',
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
emailAddress: { name: senderName, address: senderEmail },
|
||||||
|
},
|
||||||
|
toRecipients: [
|
||||||
|
{ emailAddress: { address: toEmail } },
|
||||||
|
],
|
||||||
attachments: [
|
attachments: [
|
||||||
{
|
{
|
||||||
filename: pdfFilename,
|
'@odata.type': '#microsoft.graph.fileAttachment',
|
||||||
path: pdfAbsPath,
|
name: pdfFilename,
|
||||||
contentType: 'application/pdf',
|
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> {
|
export async function sendTestEmail(
|
||||||
const transporter = createTransporter(user);
|
senderEmail: string,
|
||||||
|
senderName: string
|
||||||
|
): Promise<void> {
|
||||||
|
const cfg = await getGraphEmailConfig();
|
||||||
|
const token = await getAccessToken(cfg);
|
||||||
|
|
||||||
await transporter.sendMail({
|
const payload = {
|
||||||
from: `"${user.smtp_from_name}" <${user.smtp_from_email}>`,
|
message: {
|
||||||
to: user.smtp_from_email,
|
subject: '[NotesFrais] Test de configuration email',
|
||||||
subject: '[NotesFrais] Test de configuration SMTP',
|
body: {
|
||||||
text: 'Votre configuration SMTP fonctionne correctement ✓',
|
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)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user