From c3f7299f6c9b723cca92d0a38c5b3185ea57508c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 13:01:41 +0000 Subject: [PATCH] Configurer le serveur d'envoi de courrier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv --- package.json | 2 + pnpm-lock.yaml | 19 ++ .../migration.sql | 70 ++++++ prisma/schema.prisma | 71 ++++++ src/app/(app)/reglages/email/page.tsx | 148 ++++++++++++ src/components/settings/EmailSettingsForm.tsx | 224 ++++++++++++++++++ src/components/shell/navigation.ts | 1 + src/domain/email/message.ts | 182 ++++++++++++++ src/server/email/actions.ts | 205 ++++++++++++++++ src/server/email/mailer.ts | 173 ++++++++++++++ tests/e2e/email.spec.ts | 92 +++++++ tests/unit/email.test.ts | 175 ++++++++++++++ 12 files changed, 1362 insertions(+) create mode 100644 prisma/migrations/20260808125051_email_settings/migration.sql create mode 100644 src/app/(app)/reglages/email/page.tsx create mode 100644 src/components/settings/EmailSettingsForm.tsx create mode 100644 src/domain/email/message.ts create mode 100644 src/server/email/actions.ts create mode 100644 src/server/email/mailer.ts create mode 100644 tests/e2e/email.spec.ts create mode 100644 tests/unit/email.test.ts 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. +

+
+ + + + {settings ? : null} + +
+

+ Derniers envois +

+ {logs.length === 0 ? ( +

Aucun envoi enregistré.

+ ) : ( +
    + {logs.map((log) => ( +
  • + + {log.status === 'SENT' ? 'Envoyé' : 'Échec'} + + {log.to} + {log.subject} + {log.error ? ( + {log.error} + ) : null} + + {log.sentAt.toISOString().slice(0, 16).replace('T', ' ')} + +
  • + ))} +
+ )} +

+ 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 ( +
+ + + + + + + + + + + + + + + + +
+ + {state.error ? ( + + {state.error} + + ) : null} + {state.message ? ( + {state.message} + ) : null} +
+ + ); +} + +/** + * 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 ( +
+ + + + + {state.error ? ( +

+ {state.error} +

+ ) : null} + {state.message ? ( +

{state.message}

+ ) : null} +
+ ); +} + +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 = ` + + +
+

${escapeHtml(layout.title)}

+

${escapeHtml(layout.intro)}

+ ${layout.body + .map( + (paragraph) => + `

${escapeHtml(paragraph)}

`, + ) + .join('\n ')} + ${ + layout.action + ? `

${escapeHtml(layout.action.label)}

+

${escapeHtml(layout.action.url)}

` + : '' + } +

${escapeHtml(layout.footer)}

+
+ +`; + + return { text, html }; +} + +export interface InvitationInput { + firstName: string; + accountName: string; + url: string; + expiresAt: Date; +} + +export function invitationMessage( + to: string, + input: InvitationInput, +): Omit & { 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); +} diff --git a/src/server/email/actions.ts b/src/server/email/actions.ts new file mode 100644 index 0000000..754154d --- /dev/null +++ b/src/server/email/actions.ts @@ -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 { + 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 { + 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; +} diff --git a/src/server/email/mailer.ts b/src/server/email/mailer.ts new file mode 100644 index 0000000..1a5c71d --- /dev/null +++ b/src/server/email/mailer.ts @@ -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 { + 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 { + 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 { + 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 { + 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, + }); +} diff --git a/tests/e2e/email.spec.ts b/tests/e2e/email.spec.ts new file mode 100644 index 0000000..b0ae6d5 --- /dev/null +++ b/tests/e2e/email.spec.ts @@ -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 }, + ); +}); diff --git a/tests/unit/email.test.ts b/tests/unit/email.test.ts new file mode 100644 index 0000000..982f97c --- /dev/null +++ b/tests/unit/email.test.ts @@ -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 ', + ); + }); + + 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" '); + }); + + it('échappe les guillemets du nom', () => { + expect( + formatSender({ name: 'Le "Comptoir"', address: 'rh@example.fr' }), + ).toBe('"Le \\"Comptoir\\"" '); + }); + + 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 ', address: 'rh@example.fr' }), + ).toBe('"RH " '); + }); +}); + +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(/ { + const { html } = render({ + ...layout, + intro: 'Bonjour ', + }); + expect(html).not.toContain(''); + expect(html).toContain('<script>'); + }); +}); + +describe('échappement', () => { + it('traite les cinq caractères qui comptent', () => { + expect(escapeHtml('&')).toBe( + '<a href="x">&</a>', + ); + }); +}); + +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'); + }); +});