Configurer le serveur d'envoi de courrier
Sans serveur SMTP, PlanFlow ne peut ni inviter un salarié, ni notifier une publication de planning, ni délivrer l'information due au retour d'un arrêt. L'application n'expédie rien par elle-même : elle se connecte au serveur du client, pour que les messages partent de son domaine et que les salariés reconnaissent l'expéditeur. Le mot de passe SMTP est traité comme le NIR et l'IBAN — chiffré au repos avec la même clé, jamais renvoyé à l'écran (la page ne le charge même pas chiffré), jamais recopié dans un message d'erreur ni dans le journal d'audit. Les erreurs SMTP passent par une passe de masquage : les serveurs renvoient volontiers la commande AUTH en clair. Laisser le champ vide conserve le mot de passe enregistré, sans quoi changer un numéro de port casserait l'envoi. Enregistrer et éprouver sont deux gestes distincts : toute modification remet le réglage en « non vérifié », car un réglage non éprouvé n'est pas un réglage, c'est une intention. L'envoi de test vérifie d'abord la connexion — ce qui sépare une adresse de serveur fautive d'un mot de passe faux — puis expédie réellement. Les messages sont rendus en texte et en HTML depuis le même contenu, sans image, script ni ressource distante : la charte de télémétrie vaut aussi pour le courrier, un pixel de suivi dans un message RH est une collecte que personne n'a acceptée. Le journal d'envoi retient le destinataire et l'issue, pas le corps du message. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
12 files changed
+1362
No files matched your search
@@ -0,0 +1,205 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthorizationError } from '@/domain/access/authorize';
|
||||
import { isEmailAddress, testMessage } from '@/domain/email/message';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { mutate } from '@/server/context';
|
||||
import { encryptOptional } from '@/server/crypto';
|
||||
import { loadSmtpConfig, sendEmail, verifySmtp } from '@/server/email/mailer';
|
||||
|
||||
/**
|
||||
* Configuration du serveur d'envoi.
|
||||
*
|
||||
* Le mot de passe est traité comme un secret, au même titre que le NIR et
|
||||
* l'IBAN : chiffré au repos, jamais renvoyé à l'écran, jamais recopié dans un
|
||||
* message d'erreur. Un accès SMTP volé permet d'écrire au nom de l'entreprise.
|
||||
*/
|
||||
|
||||
export interface EmailActionState {
|
||||
error?: string;
|
||||
ok?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
class ValidationError extends Error {}
|
||||
|
||||
const settingsInput = z.object({
|
||||
host: z.string().trim().min(1, 'Serveur requis').max(255),
|
||||
port: z.coerce.number().int().min(1).max(65535),
|
||||
secure: z.boolean(),
|
||||
username: z.string().trim().max(255).optional(),
|
||||
/** Vide = on conserve le mot de passe déjà enregistré. */
|
||||
password: z.string().max(255).optional(),
|
||||
fromName: z.string().trim().min(1, 'Nom d’expéditeur requis').max(120),
|
||||
fromAddress: z.string().trim().min(1, 'Adresse d’expéditeur requise').max(255),
|
||||
replyTo: z.string().trim().max(255).optional(),
|
||||
});
|
||||
|
||||
export async function saveEmailSettingsAction(
|
||||
_previous: EmailActionState,
|
||||
formData: FormData,
|
||||
): Promise<EmailActionState> {
|
||||
const parsed = settingsInput.safeParse({
|
||||
host: formData.get('host'),
|
||||
port: formData.get('port') ?? 587,
|
||||
secure: formData.get('secure') === 'on',
|
||||
username: formData.get('username') || undefined,
|
||||
password: formData.get('password') || undefined,
|
||||
fromName: formData.get('fromName'),
|
||||
fromAddress: formData.get('fromAddress'),
|
||||
replyTo: formData.get('replyTo') || undefined,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
if (!isEmailAddress(parsed.data.fromAddress)) {
|
||||
return { error: 'L’adresse d’expéditeur n’est pas une adresse valide.' };
|
||||
}
|
||||
if (parsed.data.replyTo && !isEmailAddress(parsed.data.replyTo)) {
|
||||
return { error: 'L’adresse de réponse n’est pas une adresse valide.' };
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('settings.notifications.manage', async (db, actor) => {
|
||||
const existing = await db.emailSettings.findFirst();
|
||||
|
||||
// Un mot de passe laissé vide **conserve** l'ancien : le formulaire ne
|
||||
// peut pas le pré-remplir puisqu'il ne le reçoit jamais, et le vider à
|
||||
// chaque enregistrement casserait l'envoi au premier changement de port.
|
||||
const passwordEnc = parsed.data.password
|
||||
? encryptOptional(parsed.data.password)
|
||||
: (existing?.passwordEnc ?? null);
|
||||
|
||||
const data = {
|
||||
host: parsed.data.host,
|
||||
port: parsed.data.port,
|
||||
secure: parsed.data.secure,
|
||||
username: parsed.data.username ?? null,
|
||||
passwordEnc,
|
||||
fromName: parsed.data.fromName,
|
||||
fromAddress: parsed.data.fromAddress,
|
||||
replyTo: parsed.data.replyTo ?? null,
|
||||
// Toute modification invalide la vérification : un réglage changé n'est
|
||||
// plus le réglage éprouvé.
|
||||
verifiedAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await db.emailSettings.update({
|
||||
where: { id: existing.id },
|
||||
data: data as never,
|
||||
});
|
||||
} else {
|
||||
await db.emailSettings.create({ data: data as never });
|
||||
}
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'settings.email.update',
|
||||
entityType: 'EmailSettings',
|
||||
entityId: existing?.id ?? 'nouveau',
|
||||
before: existing
|
||||
? { host: existing.host, port: existing.port, from: existing.fromAddress }
|
||||
: null,
|
||||
// Le mot de passe n'apparaît pas, même chiffré : un journal d'audit se
|
||||
// lit plus largement qu'une table de réglages.
|
||||
after: {
|
||||
host: parsed.data.host,
|
||||
port: parsed.data.port,
|
||||
from: parsed.data.fromAddress,
|
||||
passwordChanged: Boolean(parsed.data.password),
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
return toState(error, "Vous n'avez pas le droit de modifier ces réglages.");
|
||||
}
|
||||
|
||||
revalidatePath('/reglages/email');
|
||||
return { ok: true, message: 'Réglages enregistrés. Faites un envoi de test.' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoi de test.
|
||||
*
|
||||
* Deux étapes distinctes : `verify` ouvre la connexion et authentifie, ce qui
|
||||
* sépare une adresse de serveur fautive d'un mot de passe faux ; puis l'envoi
|
||||
* réel, qui seul prouve que le message sort.
|
||||
*/
|
||||
export async function sendTestEmailAction(
|
||||
_previous: EmailActionState,
|
||||
formData: FormData,
|
||||
): Promise<EmailActionState> {
|
||||
const to = String(formData.get('to') ?? '').trim();
|
||||
if (!isEmailAddress(to)) {
|
||||
return { error: 'Indiquez une adresse de destination valide.' };
|
||||
}
|
||||
|
||||
let outcome: EmailActionState = { ok: true };
|
||||
|
||||
try {
|
||||
await mutate('settings.notifications.manage', async (db, actor) => {
|
||||
const config = await loadSmtpConfig(db);
|
||||
if (!config) {
|
||||
throw new ValidationError(
|
||||
'Enregistrez d’abord les réglages du serveur d’envoi.',
|
||||
);
|
||||
}
|
||||
|
||||
const account = await db.account.findFirst({ select: { name: true } });
|
||||
const checked = await verifySmtp(config);
|
||||
|
||||
if (!checked.ok) {
|
||||
await db.emailSettings.updateMany({
|
||||
data: { verifiedAt: null, lastError: checked.error ?? 'Échec' },
|
||||
});
|
||||
outcome = {
|
||||
error: `Connexion au serveur impossible — ${checked.error ?? 'raison inconnue'}`,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await sendEmail(
|
||||
db,
|
||||
testMessage(to, account?.name ?? 'PlanFlow'),
|
||||
'TEST',
|
||||
);
|
||||
|
||||
await db.emailSettings.updateMany({
|
||||
data: {
|
||||
verifiedAt: result.ok ? new Date() : null,
|
||||
lastError: result.ok ? null : (result.error ?? 'Échec'),
|
||||
},
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'settings.email.test',
|
||||
entityType: 'EmailSettings',
|
||||
entityId: 'test',
|
||||
after: { to, ok: result.ok },
|
||||
});
|
||||
|
||||
outcome = result.ok
|
||||
? { ok: true, message: `Message envoyé à ${to}.` }
|
||||
: { error: `Envoi refusé — ${result.error ?? 'raison inconnue'}` };
|
||||
});
|
||||
} catch (error) {
|
||||
return toState(error, "Vous n'avez pas le droit d'envoyer un test.");
|
||||
}
|
||||
|
||||
revalidatePath('/reglages/email');
|
||||
return outcome;
|
||||
}
|
||||
|
||||
function toState(error: unknown, denied: string): EmailActionState {
|
||||
if (error instanceof ValidationError) return { error: error.message };
|
||||
if (error instanceof AuthorizationError) return { error: denied };
|
||||
throw error;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import 'server-only';
|
||||
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
import {
|
||||
formatSender,
|
||||
redactSmtpError,
|
||||
type Message,
|
||||
type Sender,
|
||||
} from '@/domain/email/message';
|
||||
import { decryptOptional } from '@/server/crypto';
|
||||
import type { ScopedClient } from '@/server/tenant';
|
||||
|
||||
/**
|
||||
* Envoi de courrier — configuration par compte.
|
||||
*
|
||||
* Les réglages vivent en base, pas dans l'environnement : chaque client
|
||||
* auto-hébergé a sa propre boîte, et en changer ne doit exiger ni accès au
|
||||
* serveur ni redémarrage.
|
||||
*
|
||||
* Le mot de passe est chiffré au repos et **n'est déchiffré qu'ici**, au moment
|
||||
* d'ouvrir la connexion. Il ne remonte jamais vers un écran, jamais vers un
|
||||
* journal, jamais vers un message d'erreur.
|
||||
*/
|
||||
|
||||
export interface SmtpConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
sender: Sender;
|
||||
}
|
||||
|
||||
export type EmailKind =
|
||||
| 'TEST'
|
||||
| 'INVITATION'
|
||||
| 'PASSWORD_RESET'
|
||||
| 'PLANNING_PUBLISHED'
|
||||
| 'TIMEOFF_DECISION'
|
||||
| 'LEAVE_NOTICE';
|
||||
|
||||
export interface SendResult {
|
||||
ok: boolean;
|
||||
messageId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Charge la configuration du compte, mot de passe déchiffré. */
|
||||
export async function loadSmtpConfig(
|
||||
db: ScopedClient,
|
||||
): Promise<SmtpConfig | null> {
|
||||
const settings = await db.emailSettings.findFirst();
|
||||
if (!settings) return null;
|
||||
|
||||
return {
|
||||
host: settings.host,
|
||||
port: settings.port,
|
||||
secure: settings.secure,
|
||||
username: settings.username,
|
||||
password: decryptOptional(settings.passwordEnc),
|
||||
sender: {
|
||||
name: settings.fromName,
|
||||
address: settings.fromAddress,
|
||||
replyTo: settings.replyTo,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un message et journalise le résultat.
|
||||
*
|
||||
* Le corps n'est **pas** conservé : il porte des données personnelles et se
|
||||
* régénère. Ce qui compte, quand un salarié dit n'avoir rien reçu, c'est de
|
||||
* savoir si le serveur a accepté le message et à quelle adresse.
|
||||
*/
|
||||
export async function sendEmail(
|
||||
db: ScopedClient,
|
||||
message: Message,
|
||||
kind: EmailKind,
|
||||
membershipId?: string | null,
|
||||
): Promise<SendResult> {
|
||||
const config = await loadSmtpConfig(db);
|
||||
|
||||
if (!config) {
|
||||
const error =
|
||||
'Aucun serveur d’envoi configuré. Réglages · Envoi de courrier.';
|
||||
await logEmail(db, message, kind, 'FAILED', { error, membershipId });
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await transportFor(config).sendMail({
|
||||
from: formatSender(config.sender),
|
||||
...(config.sender.replyTo ? { replyTo: config.sender.replyTo } : {}),
|
||||
to: message.to,
|
||||
subject: message.subject,
|
||||
text: message.text,
|
||||
html: message.html,
|
||||
});
|
||||
|
||||
await logEmail(db, message, kind, 'SENT', {
|
||||
messageId: result.messageId,
|
||||
membershipId,
|
||||
});
|
||||
return { ok: true, messageId: result.messageId };
|
||||
} catch (error) {
|
||||
const redacted = redactSmtpError(error, config.username);
|
||||
await logEmail(db, message, kind, 'FAILED', {
|
||||
error: redacted,
|
||||
membershipId,
|
||||
});
|
||||
return { ok: false, error: redacted };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie la configuration sans envoyer.
|
||||
*
|
||||
* `verify` ouvre la connexion et authentifie : c'est ce qui distingue une
|
||||
* adresse de serveur fautive d'un mot de passe faux, alors qu'un envoi raté ne
|
||||
* dit que « ça n'a pas marché ».
|
||||
*/
|
||||
export async function verifySmtp(config: SmtpConfig): Promise<SendResult> {
|
||||
try {
|
||||
await transportFor(config).verify();
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: redactSmtpError(error, config.username) };
|
||||
}
|
||||
}
|
||||
|
||||
function transportFor(config: SmtpConfig) {
|
||||
return nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
// 465 chiffre dès la connexion ; 587 négocie STARTTLS. Se tromper produit
|
||||
// une erreur illisible côté serveur.
|
||||
secure: config.secure,
|
||||
...(config.username && config.password
|
||||
? { auth: { user: config.username, pass: config.password } }
|
||||
: {}),
|
||||
// Ne jamais accepter un certificat invalide : un envoi RH transporte des
|
||||
// noms et des adresses.
|
||||
tls: { rejectUnauthorized: true },
|
||||
connectionTimeout: 10_000,
|
||||
greetingTimeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function logEmail(
|
||||
db: ScopedClient,
|
||||
message: Message,
|
||||
kind: EmailKind,
|
||||
status: 'SENT' | 'FAILED',
|
||||
extra: {
|
||||
error?: string;
|
||||
messageId?: string;
|
||||
membershipId?: string | null | undefined;
|
||||
},
|
||||
): Promise<void> {
|
||||
await db.emailLog.create({
|
||||
data: {
|
||||
to: message.to,
|
||||
subject: message.subject,
|
||||
kind,
|
||||
status,
|
||||
error: extra.error ?? null,
|
||||
messageId: extra.messageId ?? null,
|
||||
membershipId: extra.membershipId ?? null,
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user