Files
notesfrais/backend/src/services/email.ts
T
Claude 8b1f3f581e 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)
2026-05-05 15:02:04 +00:00

175 lines
5.3 KiB
TypeScript

import { db } from '../db';
import { decrypt } from '../crypto';
import fs from 'fs/promises';
// ─── Types ───────────────────────────────────────────────────
interface GraphEmailConfig {
tenantId: string;
clientId: string;
clientSecret: string;
}
// ─── 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 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(
senderEmail: string,
senderName: string,
toEmail: string,
pdfAbsPath: string,
pdfFilename: string
): Promise<void> {
const cfg = await getGraphEmailConfig();
const token = await getAccessToken(cfg);
// 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 (s'envoie à soi-même) via Microsoft Graph.
* Utilisé par POST /api/settings/graph/test-email
*/
export async function sendTestEmail(
senderEmail: string,
senderName: string
): Promise<void> {
const cfg = await getGraphEmailConfig();
const token = await getAccessToken(cfg);
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)}`);
}
}