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.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;
|
||||
|
||||
@@ -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}` });
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
+156
-48
@@ -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,
|
||||
toEmail: string,
|
||||
pdfAbsPath: string,
|
||||
pdfFilename: string
|
||||
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,
|
||||
subject: 'Facture à rembourser',
|
||||
text: '',
|
||||
attachments: [
|
||||
{
|
||||
filename: pdfFilename,
|
||||
path: pdfAbsPath,
|
||||
contentType: 'application/pdf',
|
||||
// Lecture du PDF et encodage Base64
|
||||
const pdfBuffer = await fs.readFile(pdfAbsPath);
|
||||
const pdfBase64 = pdfBuffer.toString('base64');
|
||||
|
||||
const payload = {
|
||||
message: {
|
||||
subject: 'Facture à rembourser',
|
||||
body: {
|
||||
contentType: 'Text',
|
||||
content: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
from: {
|
||||
emailAddress: { name: senderName, address: senderEmail },
|
||||
},
|
||||
toRecipients: [
|
||||
{ emailAddress: { address: toEmail } },
|
||||
],
|
||||
attachments: [
|
||||
{
|
||||
'@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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user