Merge pull request #17 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre

Configurer le serveur d'envoi de courrier
This commit is contained in:
LogiFlow authored and GitHub committed 2026-08-08 15:02:11 +02:00
commit 9ed7373839
12 files changed
+1362

No files matched your search

+2
View File
@@ -31,6 +31,7 @@
"@prisma/adapter-pg": "7.9.1",
"@prisma/client": "7.9.1",
"next": "16.3.0",
"nodemailer": "^9.0.5",
"pg": "8.22.0",
"react": "19.2.8",
"react-dom": "19.2.8",
@@ -40,6 +41,7 @@
"@playwright/test": "1.62.1",
"@tailwindcss/postcss": "4.3.3",
"@types/node": "22.19.4",
"@types/nodemailer": "^8.0.1",
"@types/pg": "8.20.4",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
+19
View File
@@ -20,6 +20,9 @@ importers:
next:
specifier: 16.3.0
version: 16.3.0(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@22.19.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
nodemailer:
specifier: ^9.0.5
version: 9.0.5
pg:
specifier: 8.22.0
version: 8.22.0
@@ -42,6 +45,9 @@ importers:
'@types/node':
specifier: 22.19.4
version: 22.19.4
'@types/nodemailer':
specifier: ^8.0.1
version: 8.0.1
'@types/pg':
specifier: 8.20.4
version: 8.20.4
@@ -1153,6 +1159,9 @@ packages:
'@types/node@22.19.4':
resolution: {integrity: sha512-0yy7kLo3lc2S+O1wsTqWBjU8CX1OExoIdLWz0EM9i3Tqj9qXWG8yjDEN2ZB/coCHc0j/OQXc81gwJYHX7+uTMg==}
'@types/nodemailer@8.0.1':
resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
'@types/pg@8.20.4':
resolution: {integrity: sha512-Jz7UDOlIiFJuacC0TlBoLyNtmwlA/wpIyPDd3tvUqlRM+HzkWy2xUgpFpaXtbfTAFF6sIGq5lsCDBdJnhky1Xg==}
@@ -2563,6 +2572,10 @@ packages:
resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==}
engines: {node: '>=18'}
nodemailer@9.0.5:
resolution: {integrity: sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==}
engines: {node: '>=6.0.0'}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -4127,6 +4140,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/nodemailer@8.0.1':
dependencies:
'@types/node': 22.19.4
'@types/pg@8.20.4':
dependencies:
'@types/node': 22.19.4
@@ -5708,6 +5725,8 @@ snapshots:
node-releases@2.0.53: {}
nodemailer@9.0.5: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@@ -0,0 +1,70 @@
-- CreateEnum
CREATE TYPE "EmailKind" AS ENUM ('TEST', 'INVITATION', 'PASSWORD_RESET', 'PLANNING_PUBLISHED', 'TIMEOFF_DECISION', 'LEAVE_NOTICE');
-- CreateEnum
CREATE TYPE "EmailStatus" AS ENUM ('SENT', 'FAILED');
-- CreateTable
CREATE TABLE "EmailSettings" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"host" TEXT NOT NULL,
"port" INTEGER NOT NULL DEFAULT 587,
"secure" BOOLEAN NOT NULL DEFAULT false,
"username" TEXT,
"passwordEnc" BYTEA,
"fromName" TEXT NOT NULL,
"fromAddress" TEXT NOT NULL,
"replyTo" TEXT,
"verifiedAt" TIMESTAMP(3),
"lastError" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EmailSettings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EmailLog" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"to" TEXT NOT NULL,
"subject" TEXT NOT NULL,
"kind" "EmailKind" NOT NULL,
"status" "EmailStatus" NOT NULL,
"error" TEXT,
"messageId" TEXT,
"membershipId" TEXT,
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EmailLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "EmailSettings_accountId_key" ON "EmailSettings"("accountId");
-- CreateIndex
CREATE INDEX "EmailSettings_accountId_idx" ON "EmailSettings"("accountId");
-- CreateIndex
CREATE INDEX "EmailLog_accountId_sentAt_idx" ON "EmailLog"("accountId", "sentAt");
-- Isolation : toute table portant accountId doit porter sa politique.
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY['EmailSettings','EmailLog']
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', t);
EXECUTE format(
'CREATE POLICY tenant_isolation ON %I USING ("accountId" = planflow_current_account())', t);
EXECUTE format(
'CREATE POLICY tenant_insert ON %I FOR INSERT WITH CHECK ("accountId" = planflow_current_account())', t);
END LOOP;
END $$;
-- Le journal d'envoi atteste de ce qui a été transmis : il ne se réécrit pas.
CREATE TRIGGER email_log_append_only
BEFORE UPDATE OR DELETE ON "EmailLog"
FOR EACH ROW EXECUTE FUNCTION planflow_deny_write();
+71
View File
@@ -1094,3 +1094,74 @@ model PayPeriodSnapshot {
@@unique([payPeriodId, membershipId])
@@index([accountId])
}
// ============================================================================
// Envoi d'e-mails — configuration par compte
// ============================================================================
/// Serveur d'envoi du compte.
///
/// En base et non dans les variables d'environnement : chaque client
/// auto-hébergé a sa propre boîte, et la changer ne doit pas exiger un accès au
/// serveur ni un redémarrage.
///
/// Le mot de passe est **chiffré au repos** avec la même clé que le NIR et
/// l'IBAN (PLAN.md §3.6). Un accès SMTP volé permet d'écrire au nom de
/// l'entreprise : c'est un secret, pas un réglage.
model EmailSettings {
id String @id @default(cuid())
accountId String @unique
host String
port Int @default(587)
/// STARTTLS sur 587, TLS implicite sur 465.
secure Boolean @default(false)
username String?
passwordEnc Bytes?
/// Expéditeur affiché. Doit appartenir au domaine authentifié, sinon les
/// messages partent en indésirable.
fromName String
fromAddress String
replyTo String?
/// Faux tant qu'un envoi de test n'a pas abouti : un réglage non éprouvé
/// n'est pas un réglage, c'est une intention.
verifiedAt DateTime?
lastError String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
@@index([accountId])
}
/// Trace d'un message émis.
///
/// Sans elle, « le salarié dit qu'il n'a rien reçu » est indécidable. Le corps
/// n'est pas conservé : il porte des données personnelles et se régénère.
model EmailLog {
id String @id @default(cuid())
accountId String
to String
subject String
kind EmailKind
status EmailStatus
error String?
/// Identifiant rendu par le serveur d'envoi, utile au dépannage.
messageId String?
membershipId String?
sentAt DateTime @default(now())
@@index([accountId, sentAt])
}
enum EmailKind {
TEST
INVITATION
PASSWORD_RESET
PLANNING_PUBLISHED
TIMEOFF_DECISION
LEAVE_NOTICE
}
enum EmailStatus {
SENT
FAILED
}
+148
View File
@@ -0,0 +1,148 @@
import {
EmailSettingsForm,
TestEmailForm,
} from '@/components/settings/EmailSettingsForm';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { query, requireSession } from '@/server/context';
export const metadata = { title: 'Envoi de courrier · PlanFlow' };
/**
* Réglages d'envoi.
*
* Le mot de passe n'est **pas** chargé, même chiffré : la page n'en a pas
* besoin, et une valeur qui ne quitte jamais le serveur ne peut pas fuiter par
* le HTML.
*/
async function loadSettings() {
return query('settings.notifications.manage', async (db) => {
const settings = await db.emailSettings.findFirst({
select: {
host: true,
port: true,
secure: true,
username: true,
passwordEnc: true,
fromName: true,
fromAddress: true,
replyTo: true,
verifiedAt: true,
lastError: true,
},
});
if (!settings) return null;
const { passwordEnc, ...rest } = settings;
return { ...rest, hasPassword: passwordEnc !== null };
});
}
async function loadRecentLogs() {
return query('settings.notifications.manage', async (db) =>
db.emailLog.findMany({
orderBy: { sentAt: 'desc' },
take: 10,
select: {
id: true,
to: true,
subject: true,
kind: true,
status: true,
error: true,
sentAt: true,
},
}),
);
}
export default async function EmailSettingsPage() {
const session = await requireSession();
const settings = await loadSettings();
const logs = await loadRecentLogs();
return (
<PageBody>
<PageHeader
title="Envoi de courrier"
subtitle="Le serveur qui expédie les invitations, les notifications et les informations dues aux salariés."
actions={
settings?.verifiedAt ? (
<Badge tone="ok">
Vérifié le {settings.verifiedAt.toISOString().slice(0, 10)}
</Badge>
) : (
<Badge tone="warn">Non vérifié</Badge>
)
}
/>
{settings?.lastError ? (
<p role="alert" className="rounded-3 border border-danger bg-danger-soft p-3 text-sm text-danger-soft-ink">
Dernière tentative en échec — {settings.lastError}
</p>
) : null}
{/*
La phrase « c'est *votre* serveur qui envoie » reste affichée une fois
le réglage posé : elle répond à « depuis quelle adresse partent les
messages ? », question qui se pose surtout quand la configuration
existe déjà et qu'un salarié demande d'où vient le courrier.
*/}
<section className="rounded-3 border border-line-1 bg-surface-2 p-4 text-sm text-ink-2">
{!settings ? (
<p className="mb-2 text-ink-1">
<strong>Aucun serveur d’envoi n’est configuré.</strong> Tant que ce
réglage manque, 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.
</p>
) : null}
<p>
PlanFlow n’envoie rien par lui-même : il se connecte à{' '}
<strong>votre</strong> serveur de messagerie. Les messages partent
donc de votre domaine, et vos salariés reconnaissent l’expéditeur.
</p>
</section>
<EmailSettingsForm settings={settings} />
{settings ? <TestEmailForm defaultTo={session.user.email} /> : null}
<section className="rounded-3 border border-line-1 bg-surface">
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
Derniers envois
</h2>
{logs.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-3">Aucun envoi enregistré.</p>
) : (
<ul className="divide-y divide-line-1">
{logs.map((log) => (
<li
key={log.id}
className="flex flex-wrap items-center gap-3 px-4 py-2 text-sm"
>
<Badge tone={log.status === 'SENT' ? 'ok' : 'danger'}>
{log.status === 'SENT' ? 'Envoyé' : 'Échec'}
</Badge>
<span className="text-ink-1">{log.to}</span>
<span className="text-ink-2">{log.subject}</span>
{log.error ? (
<span className="text-micro text-danger">{log.error}</span>
) : null}
<span className="ml-auto text-micro text-ink-3">
{log.sentAt.toISOString().slice(0, 16).replace('T', ' ')}
</span>
</li>
))}
</ul>
)}
<p className="border-t border-line-1 px-4 py-2 text-micro text-ink-3">
Le contenu des messages n’est pas conservé : il porte des données
personnelles et se régénère. Le journal sert à répondre à « je n’ai
rien reçu », pas à relire le courrier.
</p>
</section>
</PageBody>
);
}
@@ -0,0 +1,224 @@
'use client';
import { useActionState } from 'react';
import { Button } from '@/components/ui/Button';
import {
saveEmailSettingsAction,
sendTestEmailAction,
type EmailActionState,
} from '@/server/email/actions';
const empty: EmailActionState = {};
export interface EmailSettingsView {
host: string;
port: number;
secure: boolean;
username: string | null;
hasPassword: boolean;
fromName: string;
fromAddress: string;
replyTo: string | null;
verifiedAt: Date | null;
lastError: string | null;
}
/**
* Réglages du serveur d'envoi.
*
* Le mot de passe n'est **jamais pré-rempli** : le serveur ne l'envoie pas à
* l'écran, même masqué. Le laisser vide conserve celui déjà enregistré — sans
* quoi changer un numéro de port casserait l'envoi.
*/
export function EmailSettingsForm({
settings,
}: {
settings: EmailSettingsView | null;
}) {
const [state, formAction, pending] = useActionState(
saveEmailSettingsAction,
empty,
);
return (
<form
action={formAction}
className="grid gap-4 rounded-3 border border-line-1 bg-surface p-4 sm:grid-cols-2"
>
<Field
label="Serveur SMTP"
name="host"
defaultValue={settings?.host ?? ''}
placeholder="smtp.example.fr"
required
hint="Fourni par votre hébergeur de messagerie."
/>
<Field
label="Port"
name="port"
type="number"
defaultValue={String(settings?.port ?? 587)}
required
hint="587 avec STARTTLS, 465 avec TLS implicite."
/>
<label className="flex items-start gap-2 text-sm text-ink-2 sm:col-span-2">
<input
name="secure"
type="checkbox"
defaultChecked={settings?.secure ?? false}
className="mt-0.5 size-4"
/>
<span>
TLS implicite (port 465)
<span className="block text-micro text-ink-3">
À cocher pour le port 465 seulement. Sur 587, laissez décoché : la
connexion est chiffrée par STARTTLS.
</span>
</span>
</label>
<Field
label="Identifiant"
name="username"
defaultValue={settings?.username ?? ''}
placeholder="souvent l’adresse complète"
hint="Laissez vide si le serveur n’exige pas d’authentification."
/>
<Field
label="Mot de passe"
name="password"
type="password"
autoComplete="new-password"
placeholder={
settings?.hasPassword ? '•••••••• (inchangé)' : 'Mot de passe SMTP'
}
hint={
settings?.hasPassword
? 'Laissez vide pour conserver celui enregistré.'
: 'Chiffré au repos, jamais réaffiché.'
}
/>
<Field
label="Nom d’expéditeur"
name="fromName"
defaultValue={settings?.fromName ?? ''}
placeholder="Maison Rivage — RH"
required
/>
<Field
label="Adresse d’expéditeur"
name="fromAddress"
type="email"
defaultValue={settings?.fromAddress ?? ''}
placeholder="rh@example.fr"
required
hint="Doit appartenir à un domaine que votre serveur est autorisé à signer (SPF, DKIM), sinon les messages partent en indésirable."
/>
<Field
label="Répondre à"
name="replyTo"
type="email"
defaultValue={settings?.replyTo ?? ''}
placeholder="Facultatif"
hint="Adresse où arrivent les réponses des salariés."
/>
<div className="flex items-center gap-3 sm:col-span-2">
<Button type="submit" variant="primary" disabled={pending}>
Enregistrer
</Button>
{state.error ? (
<span role="alert" className="text-xs text-danger">
{state.error}
</span>
) : null}
{state.message ? (
<span className="text-xs text-ok-soft-ink">{state.message}</span>
) : null}
</div>
</form>
);
}
/**
* Envoi de test.
*
* Séparé de l'enregistrement : un réglage non éprouvé n'est pas un réglage,
* c'est une intention. Tant que le test n'a pas abouti, l'écran le dit.
*/
export function TestEmailForm({ defaultTo }: { defaultTo: string }) {
const [state, formAction, pending] = useActionState(
sendTestEmailAction,
empty,
);
return (
<form
action={formAction}
className="flex flex-wrap items-end gap-3 rounded-3 border border-line-2 bg-surface-2 p-3"
>
<label className="flex flex-col gap-1">
<span className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
Envoyer un test à
</span>
<input
name="to"
type="email"
defaultValue={defaultTo}
required
className="h-8 w-64 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
/>
</label>
<Button type="submit" disabled={pending}>
Envoyer un test
</Button>
{state.error ? (
<p role="alert" className="w-full text-xs text-danger">
{state.error}
</p>
) : null}
{state.message ? (
<p className="w-full text-xs text-ok-soft-ink">{state.message}</p>
) : null}
</form>
);
}
function Field({
label,
name,
hint,
...rest
}: {
label: string;
name: string;
hint?: string;
} & React.InputHTMLAttributes<HTMLInputElement>) {
const id = `email-${name}`;
return (
<div className="flex flex-col gap-1">
<label
htmlFor={id}
className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase"
>
{label}
</label>
<input
id={id}
name={name}
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
{...rest}
/>
{hint ? <p className="text-micro text-ink-3">{hint}</p> : null}
</div>
);
}
+1
View File
@@ -83,6 +83,7 @@ export const NAVIGATION: NavSection[] = [
items: [
{ id: 'sites', label: 'Établissements', href: '/reglages/etablissements' },
{ id: 'registre', label: 'Registre de paramétrage', href: '/reglages/registre' },
{ id: 'email', label: 'Envoi de courrier', href: '/reglages/email' },
{ id: 'convention', label: 'Convention collective' },
{ id: 'postes', label: 'Postes et étiquettes' },
{ id: 'roles', label: 'Rôles et permissions' },
+182
View File
@@ -0,0 +1,182 @@
/**
* Composition des messages sortants.
*
* Séparé de l'envoi : la mise en forme d'un message se teste sans serveur SMTP,
* et c'est là que se logent les erreurs qui font finir un message en
* indésirable — un expéditeur mal formé, un sujet vide, un lien relatif.
*/
export interface Sender {
name: string;
address: string;
replyTo?: string | null;
}
export interface Message {
to: string;
subject: string;
text: string;
html: string;
}
/**
* Adresse d'expéditeur au format RFC 5322.
*
* Le nom est mis entre guillemets dès qu'il contient un caractère spécial :
* « Frouard Distribution, RH » sans guillemets casse l'en-tête à la virgule et
* le message part avec un second destinataire fantôme.
*/
export function formatSender(sender: Sender): string {
const escaped = sender.name.replace(/["\\]/g, '\\$&');
return /[",;:<>@[\]\\]/.test(sender.name)
? `"${escaped}" <${sender.address}>`
: `${sender.name} <${sender.address}>`;
}
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
export function isEmailAddress(value: string): boolean {
return EMAIL.test(value.trim());
}
/** Échappe le texte destiné au corps HTML. */
export function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export interface Layout {
title: string;
intro: string;
/** Paragraphes du corps. */
body: string[];
action?: { label: string; url: string };
footer: string;
}
/**
* Gabarit unique, en HTML et en texte.
*
* Les deux versions sont produites **du même contenu** : un message dont la
* version texte diffère du HTML est un message qu'on n'a pas relu, et beaucoup
* de filtres anti-spam le remarquent avant le destinataire.
*
* Aucune image, aucune ressource distante — la charte de télémétrie de
* PLAN.md §3.7 s'applique aussi au courrier : un pixel de suivi dans un
* message RH est une collecte que personne n'a acceptée.
*/
export function render(layout: Layout): { text: string; html: string } {
const text = [
layout.title,
'',
layout.intro,
'',
...layout.body,
layout.action ? `\n${layout.action.label} : ${layout.action.url}` : '',
'',
'—',
layout.footer,
]
.filter((line) => line !== undefined)
.join('\n')
.trim();
const html = `<!doctype html>
<html lang="fr">
<body style="margin:0;padding:24px;background:#f6f5f3;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#1c1b19">
<div style="max-width:560px;margin:0 auto;background:#ffffff;border:1px solid #e5e2dd;border-radius:12px;padding:24px">
<h1 style="margin:0 0 12px;font-size:18px;font-weight:600">${escapeHtml(layout.title)}</h1>
<p style="margin:0 0 16px;font-size:14px;line-height:1.5">${escapeHtml(layout.intro)}</p>
${layout.body
.map(
(paragraph) =>
`<p style="margin:0 0 12px;font-size:14px;line-height:1.5">${escapeHtml(paragraph)}</p>`,
)
.join('\n ')}
${
layout.action
? `<p style="margin:20px 0"><a href="${escapeHtml(layout.action.url)}" style="display:inline-block;background:#3f6f5f;color:#ffffff;text-decoration:none;padding:10px 16px;border-radius:8px;font-size:14px">${escapeHtml(layout.action.label)}</a></p>
<p style="margin:0 0 12px;font-size:12px;color:#6b6862;word-break:break-all">${escapeHtml(layout.action.url)}</p>`
: ''
}
<p style="margin:20px 0 0;padding-top:16px;border-top:1px solid #e5e2dd;font-size:12px;color:#6b6862">${escapeHtml(layout.footer)}</p>
</div>
</body>
</html>`;
return { text, html };
}
export interface InvitationInput {
firstName: string;
accountName: string;
url: string;
expiresAt: Date;
}
export function invitationMessage(
to: string,
input: InvitationInput,
): Omit<Message, 'to'> & { to: string } {
const expiry = input.expiresAt.toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
const { text, html } = render({
title: `Votre accès à PlanFlow — ${input.accountName}`,
intro: `Bonjour ${input.firstName},`,
body: [
`${input.accountName} vous a ouvert un accès à PlanFlow pour consulter vos plannings, poser vos congés et suivre vos compteurs.`,
`Ce lien est personnel et expire le ${expiry}.`,
],
action: { label: 'Définir mon mot de passe', url: input.url },
footer:
'Si vous n’attendiez pas ce message, ignorez-le : sans action de votre part, aucun compte n’est activé.',
});
return {
to,
subject: `Votre accès à PlanFlow — ${input.accountName}`,
text,
html,
};
}
export function testMessage(to: string, accountName: string): Message {
const { text, html } = render({
title: 'Test d’envoi PlanFlow',
intro: 'Ce message confirme que l’envoi de courrier fonctionne.',
body: [
`Il a été émis depuis la configuration de ${accountName}.`,
'Si vous le recevez dans les indésirables, vérifiez que l’adresse d’expéditeur appartient bien à un domaine que votre serveur est autorisé à signer (SPF, DKIM).',
],
footer: 'Message de test — aucune action attendue.',
});
return { to, subject: 'Test d’envoi PlanFlow', text, html };
}
/**
* Message d'erreur d'envoi, débarrassé de tout secret.
*
* Une erreur SMTP contient volontiers l'identifiant, parfois la commande
* complète. L'afficher à l'écran ou l'écrire au journal exposerait le mot de
* passe du serveur d'envoi.
*/
export function redactSmtpError(error: unknown, username?: string | null): string {
let message = error instanceof Error ? error.message : String(error);
// Les serveurs renvoient souvent la commande AUTH en clair dans l'erreur.
message = message.replace(/AUTH\s+\S+\s+\S+/gi, 'AUTH [masqué]');
message = message.replace(/(pass(word)?|pwd)\s*[:=]\s*\S+/gi, '$1 [masqué]');
if (username) {
message = message.split(username).join('[identifiant]');
}
return message.slice(0, 500);
}
+205
View File
@@ -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;
}
+173
View File
@@ -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,
});
}
+92
View File
@@ -0,0 +1,92 @@
import { expect, test } from '@playwright/test';
/**
* Réglages d'envoi de courrier.
*
* Deux points à protéger : le mot de passe SMTP ne doit **jamais** revenir vers
* l'écran, et un réglage non éprouvé ne doit pas se faire passer pour un
* réglage qui marche.
*/
test('l’écran explique pourquoi ce réglage est nécessaire', async ({ page }) => {
await page.goto('/reglages/email');
await expect(
page.getByRole('heading', { name: 'Envoi de courrier' }),
).toBeVisible();
// PlanFlow n'envoie rien par lui-même : les messages partent du domaine du
// client, et le dire évite la question « depuis quelle adresse ? ».
await expect(page.getByText(/votre.+serveur de messagerie/i)).toBeVisible();
});
test('un réglage non testé est annoncé comme non vérifié', async ({ page }) => {
await page.goto('/reglages/email');
// Un réglage non éprouvé n'est pas un réglage, c'est une intention.
await expect(page.getByText('Non vérifié')).toBeVisible();
});
test('le mot de passe n’est jamais renvoyé à l’écran', async ({ page }) => {
await page.goto('/reglages/email');
const secret = `secret-${Date.now()}`;
const form = page.locator('form').filter({ hasText: 'Enregistrer' });
await form.getByLabel('Serveur SMTP').fill('smtp.exemple.test');
await form.getByLabel('Port', { exact: true }).fill('587');
await form.getByLabel('Identifiant').fill('rh@exemple.test');
await form.getByLabel('Mot de passe').fill(secret);
await form.getByLabel('Nom d’expéditeur').fill('Test PlanFlow');
await form.getByLabel('Adresse d’expéditeur').fill('rh@exemple.test');
await form.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText(/Réglages enregistrés/)).toBeVisible();
// Le secret ne doit apparaître nulle part dans le HTML servi, ni en clair ni
// dans une valeur de champ.
await page.reload();
expect(await page.content()).not.toContain(secret);
await expect(form.getByLabel('Mot de passe')).toHaveValue('');
await expect(form.getByLabel('Mot de passe')).toHaveAttribute(
'placeholder',
/inchangé/,
);
});
test('une adresse d’expéditeur invalide est refusée', async ({ page }) => {
await page.goto('/reglages/email');
const form = page.locator('form').filter({ hasText: 'Enregistrer' });
await form.getByLabel('Serveur SMTP').fill('smtp.exemple.test');
await form.getByLabel('Nom d’expéditeur').fill('Test');
// `type="email"` du navigateur bloquerait une saisie manifestement fausse :
// on éprouve ici le contrôle serveur, avec une forme qui passe le premier.
await form.getByLabel('Adresse d’expéditeur').fill('rh@exemple');
await form.getByRole('button', { name: 'Enregistrer' }).click();
await expect(
form.getByText(/adresse d’expéditeur n’est pas une adresse valide/),
).toBeVisible();
});
test('un envoi de test vers un serveur injoignable échoue proprement', async ({
page,
}) => {
await page.goto('/reglages/email');
const form = page.locator('form').filter({ hasText: 'Enregistrer' });
await form.getByLabel('Serveur SMTP').fill('smtp.invalide.test');
await form.getByLabel('Port', { exact: true }).fill('587');
await form.getByLabel('Nom d’expéditeur').fill('Test PlanFlow');
await form.getByLabel('Adresse d’expéditeur').fill('rh@exemple.test');
await form.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText(/Réglages enregistrés/)).toBeVisible();
const testForm = page.locator('form').filter({ hasText: 'Envoyer un test' });
await testForm.getByLabel('Envoyer un test à').fill('destinataire@exemple.test');
await testForm.getByRole('button', { name: 'Envoyer un test' }).click();
// L'échec est annoncé, pas avalé : un réglage muet se croit fonctionnel.
await expect(testForm.getByText(/Connexion au serveur impossible/)).toBeVisible(
{ timeout: 30_000 },
);
});
+175
View File
@@ -0,0 +1,175 @@
import { describe, expect, it } from 'vitest';
import {
escapeHtml,
formatSender,
invitationMessage,
isEmailAddress,
redactSmtpError,
render,
testMessage,
} from '@/domain/email/message';
describe('adresse d’expéditeur', () => {
it('compose un en-tête simple', () => {
expect(formatSender({ name: 'Maison Rivage', address: 'rh@example.fr' })).toBe(
'Maison Rivage <rh@example.fr>',
);
});
it('met le nom entre guillemets dès qu’il contient une virgule', () => {
// Sans guillemets, l'en-tête casse à la virgule et le message part avec un
// second destinataire fantôme.
expect(
formatSender({ name: 'Maison Rivage, RH', address: 'rh@example.fr' }),
).toBe('"Maison Rivage, RH" <rh@example.fr>');
});
it('échappe les guillemets du nom', () => {
expect(
formatSender({ name: 'Le "Comptoir"', address: 'rh@example.fr' }),
).toBe('"Le \\"Comptoir\\"" <rh@example.fr>');
});
it('met entre guillemets un nom contenant un chevron', () => {
// Un chevron non protégé ouvrirait une seconde adresse dans l'en-tête.
expect(
formatSender({ name: 'RH <interne>', address: 'rh@example.fr' }),
).toBe('"RH <interne>" <rh@example.fr>');
});
});
describe('validation d’adresse', () => {
it('accepte les formes usuelles', () => {
expect(isEmailAddress('rh@example.fr')).toBe(true);
expect(isEmailAddress('prenom.nom+rh@sous.example.co.uk')).toBe(true);
});
it('refuse ce qui n’en est pas une', () => {
expect(isEmailAddress('rh@example')).toBe(false);
expect(isEmailAddress('rh example.fr')).toBe(false);
expect(isEmailAddress('')).toBe(false);
});
});
describe('gabarit', () => {
const layout = {
title: 'Titre',
intro: 'Bonjour Rémi,',
body: ['Premier paragraphe.', 'Second paragraphe.'],
action: { label: 'Ouvrir', url: 'https://exemple.test/a?b=c' },
footer: 'Pied de message.',
};
it('produit les deux versions du même contenu', () => {
// Un message dont la version texte diffère du HTML est un message qu'on n'a
// pas relu, et beaucoup de filtres le remarquent avant le destinataire.
const { text, html } = render(layout);
for (const paragraph of layout.body) {
expect(text).toContain(paragraph);
expect(html).toContain(paragraph);
}
expect(text).toContain(layout.action.url);
expect(html).toContain(layout.action.url);
});
it('rend le lien en clair dans la version texte', () => {
const { text } = render(layout);
expect(text).toContain('Ouvrir : https://exemple.test/a?b=c');
});
it('n’embarque aucune 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.
const { html } = render(layout);
expect(html).not.toMatch(/<img/i);
expect(html).not.toMatch(/<script/i);
expect(html).not.toMatch(/url\(/i);
});
it('échappe le HTML du contenu', () => {
const { html } = render({
...layout,
intro: 'Bonjour <script>alert(1)</script>',
});
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).toContain('&lt;script&gt;');
});
});
describe('échappement', () => {
it('traite les cinq caractères qui comptent', () => {
expect(escapeHtml('<a href="x">&</a>')).toBe(
'&lt;a href=&quot;x&quot;&gt;&amp;&lt;/a&gt;',
);
});
});
describe('message d’invitation', () => {
const message = invitationMessage('salarie@example.test', {
firstName: 'Rémi',
accountName: 'Maison Rivage',
url: 'https://planflow.example/invitation/abc',
expiresAt: new Date('2026-09-15T00:00:00Z'),
});
it('nomme le destinataire et l’entreprise', () => {
expect(message.to).toBe('salarie@example.test');
expect(message.text).toContain('Rémi');
expect(message.text).toContain('Maison Rivage');
});
it('annonce la date d’expiration', () => {
// Un lien qui expire sans le dire produit un appel au support.
expect(message.text).toContain('15 septembre 2026');
});
it('dit quoi faire si le message n’était pas attendu', () => {
expect(message.text).toMatch(/ignorez-le/);
});
});
describe('message de test', () => {
it('explique le cas des indésirables', () => {
// C'est la première difficulté rencontrée en configurant un envoi : le
// message part mais n'arrive pas.
const message = testMessage('rh@example.fr', 'Maison Rivage');
expect(message.text).toMatch(/SPF, DKIM/);
});
});
describe('erreurs SMTP', () => {
it('masque la commande d’authentification', () => {
// Les serveurs renvoient volontiers la commande AUTH en clair : la
// recopier à l'écran ou au journal exposerait le mot de passe.
const redacted = redactSmtpError(
new Error('Invalid command: AUTH PLAIN AHJoQGV4YW1wbGUuZnIAczNjcmV0'),
);
expect(redacted).toContain('AUTH [masqué]');
expect(redacted).not.toContain('AHJoQGV4YW1wbGUuZnIAczNjcmV0');
});
it('masque un mot de passe nommé', () => {
expect(redactSmtpError(new Error('login failed: password=s3cret'))).toBe(
'login failed: password [masqué]',
);
});
it('remplace l’identifiant par un libellé', () => {
const redacted = redactSmtpError(
new Error('535 Authentication failed for rh@example.fr'),
'rh@example.fr',
);
expect(redacted).toContain('[identifiant]');
expect(redacted).not.toContain('rh@example.fr');
});
it('borne la longueur du message', () => {
const redacted = redactSmtpError(new Error('x'.repeat(2000)));
expect(redacted.length).toBeLessThanOrEqual(500);
});
it('accepte une valeur qui n’est pas une erreur', () => {
expect(redactSmtpError('ECONNREFUSED')).toBe('ECONNREFUSED');
});
});