Configurer le serveur d'envoi de courrier
Sans serveur SMTP, PlanFlow ne peut ni inviter un salarié, ni notifier une publication de planning, ni délivrer l'information due au retour d'un arrêt. L'application n'expédie rien par elle-même : elle se connecte au serveur du client, pour que les messages partent de son domaine et que les salariés reconnaissent l'expéditeur. Le mot de passe SMTP est traité comme le NIR et l'IBAN — chiffré au repos avec la même clé, jamais renvoyé à l'écran (la page ne le charge même pas chiffré), jamais recopié dans un message d'erreur ni dans le journal d'audit. Les erreurs SMTP passent par une passe de masquage : les serveurs renvoient volontiers la commande AUTH en clair. Laisser le champ vide conserve le mot de passe enregistré, sans quoi changer un numéro de port casserait l'envoi. Enregistrer et éprouver sont deux gestes distincts : toute modification remet le réglage en « non vérifié », car un réglage non éprouvé n'est pas un réglage, c'est une intention. L'envoi de test vérifie d'abord la connexion — ce qui sépare une adresse de serveur fautive d'un mot de passe faux — puis expédie réellement. Les messages sont rendus en texte et en HTML depuis le même contenu, sans image, script ni ressource distante : la charte de télémétrie vaut aussi pour le courrier, un pixel de suivi dans un message RH est une collecte que personne n'a acceptée. Le journal d'envoi retient le destinataire et l'issue, pas le corps du message. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
12 files changed
+1362
No files matched your search
@@ -0,0 +1,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 },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
escapeHtml,
|
||||
formatSender,
|
||||
invitationMessage,
|
||||
isEmailAddress,
|
||||
redactSmtpError,
|
||||
render,
|
||||
testMessage,
|
||||
} from '@/domain/email/message';
|
||||
|
||||
describe('adresse d’expéditeur', () => {
|
||||
it('compose un en-tête simple', () => {
|
||||
expect(formatSender({ name: 'Maison Rivage', address: 'rh@example.fr' })).toBe(
|
||||
'Maison Rivage <rh@example.fr>',
|
||||
);
|
||||
});
|
||||
|
||||
it('met le nom entre guillemets dès qu’il contient une virgule', () => {
|
||||
// Sans guillemets, l'en-tête casse à la virgule et le message part avec un
|
||||
// second destinataire fantôme.
|
||||
expect(
|
||||
formatSender({ name: 'Maison Rivage, RH', address: 'rh@example.fr' }),
|
||||
).toBe('"Maison Rivage, RH" <rh@example.fr>');
|
||||
});
|
||||
|
||||
it('échappe les guillemets du nom', () => {
|
||||
expect(
|
||||
formatSender({ name: 'Le "Comptoir"', address: 'rh@example.fr' }),
|
||||
).toBe('"Le \\"Comptoir\\"" <rh@example.fr>');
|
||||
});
|
||||
|
||||
it('met entre guillemets un nom contenant un chevron', () => {
|
||||
// Un chevron non protégé ouvrirait une seconde adresse dans l'en-tête.
|
||||
expect(
|
||||
formatSender({ name: 'RH <interne>', address: 'rh@example.fr' }),
|
||||
).toBe('"RH <interne>" <rh@example.fr>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation d’adresse', () => {
|
||||
it('accepte les formes usuelles', () => {
|
||||
expect(isEmailAddress('rh@example.fr')).toBe(true);
|
||||
expect(isEmailAddress('prenom.nom+rh@sous.example.co.uk')).toBe(true);
|
||||
});
|
||||
|
||||
it('refuse ce qui n’en est pas une', () => {
|
||||
expect(isEmailAddress('rh@example')).toBe(false);
|
||||
expect(isEmailAddress('rh example.fr')).toBe(false);
|
||||
expect(isEmailAddress('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gabarit', () => {
|
||||
const layout = {
|
||||
title: 'Titre',
|
||||
intro: 'Bonjour Rémi,',
|
||||
body: ['Premier paragraphe.', 'Second paragraphe.'],
|
||||
action: { label: 'Ouvrir', url: 'https://exemple.test/a?b=c' },
|
||||
footer: 'Pied de message.',
|
||||
};
|
||||
|
||||
it('produit les deux versions du même contenu', () => {
|
||||
// Un message dont la version texte diffère du HTML est un message qu'on n'a
|
||||
// pas relu, et beaucoup de filtres le remarquent avant le destinataire.
|
||||
const { text, html } = render(layout);
|
||||
for (const paragraph of layout.body) {
|
||||
expect(text).toContain(paragraph);
|
||||
expect(html).toContain(paragraph);
|
||||
}
|
||||
expect(text).toContain(layout.action.url);
|
||||
expect(html).toContain(layout.action.url);
|
||||
});
|
||||
|
||||
it('rend le lien en clair dans la version texte', () => {
|
||||
const { text } = render(layout);
|
||||
expect(text).toContain('Ouvrir : https://exemple.test/a?b=c');
|
||||
});
|
||||
|
||||
it('n’embarque aucune ressource distante', () => {
|
||||
// La charte de télémétrie vaut aussi pour le courrier : un pixel de suivi
|
||||
// dans un message RH est une collecte que personne n'a acceptée.
|
||||
const { html } = render(layout);
|
||||
expect(html).not.toMatch(/<img/i);
|
||||
expect(html).not.toMatch(/<script/i);
|
||||
expect(html).not.toMatch(/url\(/i);
|
||||
});
|
||||
|
||||
it('échappe le HTML du contenu', () => {
|
||||
const { html } = render({
|
||||
...layout,
|
||||
intro: 'Bonjour <script>alert(1)</script>',
|
||||
});
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html).toContain('<script>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('échappement', () => {
|
||||
it('traite les cinq caractères qui comptent', () => {
|
||||
expect(escapeHtml('<a href="x">&</a>')).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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user