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'); + }); +});