diff --git a/package.json b/package.json
index d530695..24edbef 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 60e4640..0b32848 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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: {}
diff --git a/prisma/migrations/20260808125051_email_settings/migration.sql b/prisma/migrations/20260808125051_email_settings/migration.sql
new file mode 100644
index 0000000..29582ec
--- /dev/null
+++ b/prisma/migrations/20260808125051_email_settings/migration.sql
@@ -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();
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 844d4ba..7bcaf00 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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
+}
diff --git a/src/app/(app)/reglages/email/page.tsx b/src/app/(app)/reglages/email/page.tsx
new file mode 100644
index 0000000..332672c
--- /dev/null
+++ b/src/app/(app)/reglages/email/page.tsx
@@ -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 (
+
+
+ Vérifié le {settings.verifiedAt.toISOString().slice(0, 10)}
+
+ ) : (
+ Non vérifié
+ )
+ }
+ />
+
+ {settings?.lastError ? (
+
+ Dernière tentative en échec — {settings.lastError}
+
+ ) : 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.
+ */}
+
+ {!settings ? (
+
+ Aucun serveur d’envoi n’est configuré. 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.
+
+ ) : null}
+
+ PlanFlow n’envoie rien par lui-même : il se connecte à{' '}
+ votre serveur de messagerie. Les messages partent
+ donc de votre domaine, et vos salariés reconnaissent l’expéditeur.
+
+ 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.
+
+
+
+ );
+}
diff --git a/src/components/settings/EmailSettingsForm.tsx b/src/components/settings/EmailSettingsForm.tsx
new file mode 100644
index 0000000..05317ad
--- /dev/null
+++ b/src/components/settings/EmailSettingsForm.tsx
@@ -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 (
+
+ );
+}
+
+/**
+ * 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 (
+
+ );
+}
+
+function Field({
+ label,
+ name,
+ hint,
+ ...rest
+}: {
+ label: string;
+ name: string;
+ hint?: string;
+} & React.InputHTMLAttributes) {
+ const id = `email-${name}`;
+ return (
+
+
+
+ {hint ?
{hint}
: null}
+
+ );
+}
diff --git a/src/components/shell/navigation.ts b/src/components/shell/navigation.ts
index 678add6..b6be184 100644
--- a/src/components/shell/navigation.ts
+++ b/src/components/shell/navigation.ts
@@ -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' },
diff --git a/src/domain/email/message.ts b/src/domain/email/message.ts
new file mode 100644
index 0000000..0951040
--- /dev/null
+++ b/src/domain/email/message.ts
@@ -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, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+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 = `
+
+
+