From ac6409721a1fd4e6699cb5df72ea82aae3bcbc23 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C3=ABl=20Bitard?= <bitard.michael@gmail.com>
Date: Tue, 19 Mar 2024 17:40:22 +0100
Subject: [PATCH] =?UTF-8?q?feat(mail):=20les=20emails=20envoy=C3=A9s=20n'u?=
 =?UTF-8?q?tilisent=20plus=20camino@beta.gouv.fr=20(#1080)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .env-example                                  | 15 +++++
 .github/workflows/api.yml                     |  5 +-
 infra/roles/camino/templates/daily            |  2 +-
 infra/roles/camino/templates/env              |  3 +-
 infra/roles/camino/templates/monthly          |  2 +-
 packages/api/package.json                     |  2 +-
 packages/api/src/@types/global.d.ts           | 11 ----
 .../_titre-etape-email.test.ts.snap           |  4 +-
 .../api/src/api/graphql/resolvers/metas.ts    |  3 +-
 .../src/api/rest/keycloak.test.integration.ts |  3 +
 packages/api/src/api/rest/keycloak.ts         | 16 ++---
 .../api/rest/utilisateurs.test.integration.ts |  3 +
 packages/api/src/api/rest/utilisateurs.ts     | 23 +++----
 .../api/src/business/utils/urls-get.test.ts   |  8 +--
 packages/api/src/business/utils/urls-get.ts   |  5 +-
 packages/api/src/config/index.ts              | 61 ++++++++++++++++++-
 packages/api/src/database/init.ts             |  4 +-
 packages/api/src/index.ts                     | 34 +++++------
 packages/api/src/init.ts                      |  4 --
 packages/api/src/knex/config.ts               | 11 ++--
 packages/api/src/scripts/daily.ts             | 22 ++++---
 .../api/src/scripts/keycloak-migration.ts     |  3 +-
 packages/api/src/scripts/matrices.ts          | 13 ++--
 packages/api/src/scripts/monthly.ts           | 20 +++---
 packages/api/src/server/auth-jwt.ts           | 12 ++--
 .../api/src/server/config.test.integration.ts |  4 +-
 packages/api/src/server/rest.ts               | 24 +++++---
 packages/api/src/server/user-loader.ts        |  5 +-
 .../src/tools/api-administrations/index.ts    | 18 +++---
 packages/api/src/tools/api-insee/fetch.ts     | 22 ++++---
 packages/api/src/tools/api-mailjet/emails.ts  | 12 ++--
 packages/api/src/tools/api-mailjet/index.ts   |  5 +-
 .../api/src/tools/api-mailjet/newsletter.ts   | 20 +++---
 packages/api/src/tools/api-openfisca/index.ts |  7 +--
 packages/api/test-env.ts                      | 32 ++++++++++
 packages/api/tests/_utils/index.ts            |  6 +-
 packages/api/tests/db-manager.ts              |  5 +-
 packages/api/vitest.integration.config.ts     |  2 +
 packages/api/vitest.unit.config.ts            |  2 +
 packages/common/src/static/config.ts          |  9 ++-
 packages/ui/src/index.ts                      | 17 +++---
 41 files changed, 300 insertions(+), 179 deletions(-)
 create mode 100644 packages/api/test-env.ts

diff --git a/.env-example b/.env-example
index 439c5b01f..9274155ee 100644
--- a/.env-example
+++ b/.env-example
@@ -53,6 +53,7 @@ ADMIN_EMAIL=email@domaine.com
 
 # email
 API_MAILJET_EMAIL=email@domaine.com
+API_MAILJET_REPLY_TO_EMAIL=email@domaine.com
 API_MAILJET_KEY=
 API_MAILJET_SECRET=
 API_MAILJET_SERVER=in-v3.mailjet.com
@@ -64,3 +65,17 @@ API_OPENFISCA_URL="http://localhost:8000"
 
 # certificat ssh (docker compose)
 LETSENCRYPT_EMAIL=""
+APPLICATION_VERSION=version
+
+
+API_INSEE_URL=something
+API_INSEE_KEY=something
+API_INSEE_SECRET=something
+
+SENTRY_DSN=something
+API_MATOMO_URL=something
+API_MATOMO_ID=something
+
+API_ADMINISTRATION_URL="http://something"
+
+OAUTH_URL="http://localhost:4180"
\ No newline at end of file
diff --git a/.github/workflows/api.yml b/.github/workflows/api.yml
index 62169cb4f..af473c4ff 100644
--- a/.github/workflows/api.yml
+++ b/.github/workflows/api.yml
@@ -127,7 +127,10 @@ jobs:
             cache: 'npm'
         - name: Npm install
           run: make install
-
+        - uses: cardinalby/export-env-action@v2
+          with:
+            envFile: '.env-example'    
+            expand: 'true'
         - name: Migrate database and check queries
           run: | 
             make db/migrate
diff --git a/infra/roles/camino/templates/daily b/infra/roles/camino/templates/daily
index fa7ffdc20..330c6d3a1 100644
--- a/infra/roles/camino/templates/daily
+++ b/infra/roles/camino/templates/daily
@@ -1,3 +1,3 @@
 #!/bin/bash
 
-docker exec -t -e CAMINO_STAGE=true camino_api_app make daily
\ No newline at end of file
+docker exec -t -e CAMINO_STAGE={{ env }} camino_api_app make daily
\ No newline at end of file
diff --git a/infra/roles/camino/templates/env b/infra/roles/camino/templates/env
index 4d710e0b6..2c2dca064 100644
--- a/infra/roles/camino/templates/env
+++ b/infra/roles/camino/templates/env
@@ -55,7 +55,8 @@ ADMIN_EMAIL=camino@beta.gouv.fr
 
 
 # API Mailjet
-API_MAILJET_EMAIL=camino@beta.gouv.fr
+API_MAILJET_REPLY_TO_EMAIL=camino@beta.gouv.fr
+API_MAILJET_EMAIL=contact@camino.beta.gouv.fr
 API_MAILJET_KEY={{ mailjet_key }}
 API_MAILJET_SECRET={{ mailjet_secret }}
 API_MAILJET_SERVER=in-v3.mailjet.com
diff --git a/infra/roles/camino/templates/monthly b/infra/roles/camino/templates/monthly
index e416375cb..c33aadc23 100644
--- a/infra/roles/camino/templates/monthly
+++ b/infra/roles/camino/templates/monthly
@@ -1,3 +1,3 @@
 #!/bin/bash
 
-docker exec -t -e CAMINO_STAGE=true camino_api_app make monthly
\ No newline at end of file
+docker exec -t -e CAMINO_STAGE={{ env }} camino_api_app make monthly
\ No newline at end of file
diff --git a/packages/api/package.json b/packages/api/package.json
index 0cd4623af..965b4f211 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -28,7 +28,7 @@
     "start": "node --loader ts-node/esm/transpile-only ./src/index.ts",
     "test": "npm run test:unit && npm run test:integration",
     "test:unit": "vitest --environment node --root src/ --config ../vitest.unit.config.ts",
-    "test:integration": "NODE_OPTIONS='--loader ts-node/esm/transpile-only' JWT_SECRET=secret-tests vitest --environment node --root src/ --config ../vitest.integration.config.ts",
+    "test:integration": "NODE_OPTIONS='--loader ts-node/esm/transpile-only' vitest --environment node --root src/ --config ../vitest.integration.config.ts",
     "test:generate-data": "node --loader ts-node/esm/transpile-only src/tools/demarches/tests-creation.ts",
     "test:generate-sections-data": "node --loader ts-node/esm/transpile-only src/tools/activites/tests-creation.ts",
     "ci:lint": "prettier --check . && eslint .",
diff --git a/packages/api/src/@types/global.d.ts b/packages/api/src/@types/global.d.ts
index f4ef5db26..f4cd56cec 100644
--- a/packages/api/src/@types/global.d.ts
+++ b/packages/api/src/@types/global.d.ts
@@ -8,15 +8,4 @@ declare global {
   interface Array<T> {
     includes<U>(_searchElement: U & (T & U extends never ? never : unknown), _fromIndex?: number): boolean
   }
-  namespace NodeJS {
-    interface ProcessEnv {
-      CAMINO_STAGE?: 'dev' | 'preprod' | 'prod'
-      SENTRY_DSN?: string
-      ENV: string
-      UI_HOST: string
-      API_MATOMO_URL: string
-      API_MATOMO_ID: string
-      APPLICATION_VERSION: string
-    }
-  }
 }
diff --git a/packages/api/src/api/graphql/resolvers/__snapshots__/_titre-etape-email.test.ts.snap b/packages/api/src/api/graphql/resolvers/__snapshots__/_titre-etape-email.test.ts.snap
index 99c7fcfbd..c4a9d1377 100644
--- a/packages/api/src/api/graphql/resolvers/__snapshots__/_titre-etape-email.test.ts.snap
+++ b/packages/api/src/api/graphql/resolvers/__snapshots__/_titre-etape-email.test.ts.snap
@@ -7,7 +7,7 @@ exports[`envoie un email sur un octroi d'AEX 1`] = `
   
   <hr>
   
-  <b>Lien</b> : <a href=\\"undefined/titres/titreId\\">undefined/titres/titreId</a> <br>
+  <b>Lien</b> : <a href=\\"http://plop.plop/titres/titreId\\">http://plop.plop/titres/titreId</a> <br>
   <b>Effectué par</b> :  Camino (camino@beta.gouv.fr)<br>
   
   ",
@@ -25,7 +25,7 @@ exports[`envoie un email sur un octroi d'ARM 1`] = `
   
   <hr>
   
-  <b>Lien</b> : <a href=\\"undefined/titres/titreId\\">undefined/titres/titreId</a> <br>
+  <b>Lien</b> : <a href=\\"http://plop.plop/titres/titreId\\">http://plop.plop/titres/titreId</a> <br>
   <b>Effectué par</b> :  Camino (camino@beta.gouv.fr)<br>
   
   ",
diff --git a/packages/api/src/api/graphql/resolvers/metas.ts b/packages/api/src/api/graphql/resolvers/metas.ts
index e5ebcda13..36d4fc44b 100644
--- a/packages/api/src/api/graphql/resolvers/metas.ts
+++ b/packages/api/src/api/graphql/resolvers/metas.ts
@@ -13,6 +13,7 @@ import { sortedAdministrationTypes } from 'camino-common/src/static/administrati
 import { sortedDomaines } from 'camino-common/src/static/domaines.js'
 import { sortedTitreTypesTypes } from 'camino-common/src/static/titresTypesTypes.js'
 import { sortedDocumentTypes } from 'camino-common/src/static/documentsTypes.js'
+import { config } from '../../../config/index.js'
 
 export const devises = async () => devisesGet()
 
@@ -44,7 +45,7 @@ export const demarchesStatuts = async () => {
 
 export const etapesStatuts = () => Object.values(EtapesStatuts)
 
-export const version = () => process.env.APPLICATION_VERSION
+export const version = () => config().APPLICATION_VERSION
 
 /**
  * Retourne les types d'administrations
diff --git a/packages/api/src/api/rest/keycloak.test.integration.ts b/packages/api/src/api/rest/keycloak.test.integration.ts
index 3d42ca3c6..7fe70af11 100644
--- a/packages/api/src/api/rest/keycloak.test.integration.ts
+++ b/packages/api/src/api/rest/keycloak.test.integration.ts
@@ -4,6 +4,7 @@ import { restCall } from '../../../tests/_utils/index.js'
 import { test, expect, vi, beforeAll, afterAll } from 'vitest'
 import type { Pool } from 'pg'
 import { HTTP_STATUS } from 'camino-common/src/http.js'
+import { renewConfig } from '../../config/index.js'
 
 console.info = vi.fn()
 console.error = vi.fn()
@@ -22,6 +23,7 @@ afterAll(async () => {
 test('deconnecter', async () => {
   process.env.KEYCLOAK_LOGOUT_URL = 'https://notexisting.url'
   process.env.OAUTH_URL = 'https://another.notexisting.url'
+  renewConfig()
   const tested = await restCall(dbPool, '/deconnecter', {}, userSuper)
 
   expect(tested.statusCode).toBe(HTTP_STATUS.HTTP_STATUS_FOUND)
@@ -29,6 +31,7 @@ test('deconnecter', async () => {
 
 test('resetPassword', async () => {
   process.env.KEYCLOAK_RESET_PASSWORD_URL = 'https://notexisting.url'
+  renewConfig()
   const tested = await restCall(dbPool, '/changerMotDePasse', {}, userSuper)
 
   expect(tested.statusCode).toBe(HTTP_STATUS.HTTP_STATUS_FOUND)
diff --git a/packages/api/src/api/rest/keycloak.ts b/packages/api/src/api/rest/keycloak.ts
index a2b5dc723..3b266d378 100644
--- a/packages/api/src/api/rest/keycloak.ts
+++ b/packages/api/src/api/rest/keycloak.ts
@@ -1,15 +1,17 @@
 import { Pool } from 'pg'
 import { CaminoRequest, CustomResponse } from './express-type'
+import { config } from '../../config/index.js'
+import { isNullOrUndefined } from 'camino-common/src/typescript-tools.js'
 
 export const logout = (_pool: Pool) => async (req: CaminoRequest, res: CustomResponse<string>) => {
   const authorizationToken = req.header('authorization')
-  if (!authorizationToken) {
+  if (isNullOrUndefined(authorizationToken)) {
     res.sendStatus(403)
   } else {
     const token = authorizationToken.substring(7)
-    const uiUrl = process.env.OAUTH_URL ?? ''
+    const uiUrl = config().OAUTH_URL
 
-    const keycloakLogoutUrl = new URL(process.env.KEYCLOAK_LOGOUT_URL ?? '')
+    const keycloakLogoutUrl = new URL(config().KEYCLOAK_LOGOUT_URL)
     keycloakLogoutUrl.searchParams.append('post_logout_redirect_uri', uiUrl)
     keycloakLogoutUrl.searchParams.append('id_token_hint', token)
 
@@ -21,14 +23,14 @@ export const logout = (_pool: Pool) => async (req: CaminoRequest, res: CustomRes
 }
 export const resetPassword = (_pool: Pool) => async (req: CaminoRequest, res: CustomResponse<string>) => {
   const authorizationToken = req.header('authorization')
-  if (!authorizationToken) {
+  if (isNullOrUndefined(authorizationToken)) {
     res.sendStatus(403)
   } else {
-    const uiUrl = process.env.OAUTH_URL ?? ''
+    const uiUrl = config().OAUTH_URL
 
-    const resetPasswordUrl = new URL(process.env.KEYCLOAK_RESET_PASSWORD_URL ?? '')
+    const resetPasswordUrl = new URL(config().KEYCLOAK_RESET_PASSWORD_URL)
     resetPasswordUrl.searchParams.append('response_type', 'code')
-    resetPasswordUrl.searchParams.append('client_id', process.env.KEYCLOAK_CLIENT_ID ?? '')
+    resetPasswordUrl.searchParams.append('client_id', config().KEYCLOAK_CLIENT_ID)
     resetPasswordUrl.searchParams.append('kc_action', 'UPDATE_PASSWORD')
     resetPasswordUrl.searchParams.append('redirect_uri', uiUrl)
 
diff --git a/packages/api/src/api/rest/utilisateurs.test.integration.ts b/packages/api/src/api/rest/utilisateurs.test.integration.ts
index 2de7f8325..1ddb07d84 100644
--- a/packages/api/src/api/rest/utilisateurs.test.integration.ts
+++ b/packages/api/src/api/rest/utilisateurs.test.integration.ts
@@ -8,6 +8,7 @@ import { HTTP_STATUS } from 'camino-common/src/http.js'
 import { userSuper } from '../../database/user-super.js'
 import { newUtilisateurId } from '../../database/models/_format/id-create.js'
 import { KeycloakFakeServer, idUserKeycloakRecognised, setupKeycloak, teardownKeycloak } from '../../../tests/keycloak.js'
+import { renewConfig } from '../../config/index.js'
 
 console.info = vi.fn()
 console.error = vi.fn()
@@ -19,6 +20,7 @@ beforeAll(async () => {
   dbPool = pool
   knex = knexInstance
   keycloak = await setupKeycloak()
+  renewConfig()
 })
 
 afterAll(async () => {
@@ -97,6 +99,7 @@ describe('utilisateurSupprimer', () => {
   test('peut supprimer son compte utilisateur', async () => {
     const OAUTH_URL = 'http://unused'
     process.env.OAUTH_URL = OAUTH_URL
+    renewConfig()
     const user = await userGenerate({ role: 'defaut' })
 
     const tested = await restCall(dbPool, '/rest/utilisateurs/:id/delete', { id: user.id }, { role: 'defaut' })
diff --git a/packages/api/src/api/rest/utilisateurs.ts b/packages/api/src/api/rest/utilisateurs.ts
index 9b9fc3105..8ccb52da8 100644
--- a/packages/api/src/api/rest/utilisateurs.ts
+++ b/packages/api/src/api/rest/utilisateurs.ts
@@ -16,6 +16,7 @@ import { canDeleteUtilisateur } from 'camino-common/src/permissions/utilisateurs
 import { DownloadFormat } from 'camino-common/src/rest.js'
 import { Pool } from 'pg'
 import { isNotNullNorUndefined, isNullOrUndefined } from 'camino-common/src/typescript-tools.js'
+import { config } from '../../config/index.js'
 
 export const isSubscribedToNewsletter = (_pool: Pool) => async (req: CaminoRequest, res: CustomResponse<boolean>) => {
   const user = req.auth
@@ -73,9 +74,9 @@ export const updateUtilisateurPermission = (_pool: Pool) => async (req: CaminoRe
 export type KeycloakAccessTokenResponse = { access_token: string }
 
 export const getKeycloakApiToken = async (): Promise<string> => {
-  const client_id = process.env.KEYCLOAK_API_CLIENT_ID
-  const client_secret = process.env.KEYCLOAK_API_CLIENT_SECRET
-  const url = process.env.KEYCLOAK_URL
+  const client_id = config().KEYCLOAK_API_CLIENT_ID
+  const client_secret = config().KEYCLOAK_API_CLIENT_SECRET
+  const url = config().KEYCLOAK_URL
 
   if (!client_id || !client_secret || !url) {
     throw new Error('variables KEYCLOAK_API_CLIENT_ID and KEYCLOAK_API_CLIENT_SECRET and KEYCLOAK_URL must be set')
@@ -124,7 +125,7 @@ export const deleteUtilisateur = (_pool: Pool) => async (req: CaminoRequest, res
 
       const authorizationToken = await getKeycloakApiToken()
 
-      const deleteFromKeycloak = await fetch(`${process.env.KEYCLOAK_URL}/admin/realms/Camino/users/${utilisateurKeycloakId}`, {
+      const deleteFromKeycloak = await fetch(`${config().KEYCLOAK_URL}/admin/realms/Camino/users/${utilisateurKeycloakId}`, {
         method: 'DELETE',
         headers: {
           authorization: `Bearer ${authorizationToken}`,
@@ -146,7 +147,7 @@ export const deleteUtilisateur = (_pool: Pool) => async (req: CaminoRequest, res
       await utilisateurUpsert(utilisateur)
 
       if (isNotNullNorUndefined(user) && user.id === req.params.id) {
-        const uiUrl = process.env.OAUTH_URL
+        const uiUrl = config().OAUTH_URL
         const oauthLogoutUrl = new URL(`${uiUrl}/oauth2/sign_out`)
         res.redirect(oauthLogoutUrl.href)
       } else {
@@ -211,7 +212,7 @@ export const generateQgisToken = (_pool: Pool) => async (req: CaminoRequest, res
     await knex('utilisateurs')
       .update({ qgis_token: bcrypt.hashSync(token, 10) })
       .where('email', user.email)
-    res.send({ token, url: `https://${user.email.replace('@', '%40')}:${token}@${process.env.API_HOST ?? 'api.camino.beta.gouv.fr'}/titres_qgis` })
+    res.send({ token, url: `https://${user.email.replace('@', '%40')}:${token}@${config().API_HOST}/titres_qgis` })
   }
 }
 
@@ -220,9 +221,9 @@ interface IUtilisateursQueryInput {
   colonne?: IUtilisateursColonneId | null
   ordre?: 'asc' | 'desc' | null
   entrepriseIds?: string | string[]
-  administrationIds?: string
+  administrationIds?: string | string[]
   //  TODO 2022-06-14: utiliser un tableau de string plutôt qu'une chaine séparée par des ','
-  roles?: string
+  roles?: string | string[]
   noms?: string | null
   nomsUtilisateurs?: string | null
   emails?: string | null
@@ -235,9 +236,9 @@ export const utilisateurs =
       {
         colonne,
         ordre,
-        entreprisesIds: entrepriseIds ? (Array.isArray(entrepriseIds) ? entrepriseIds : entrepriseIds.split(',')) : undefined,
-        administrationIds: administrationIds ? (Array.isArray(administrationIds) ? administrationIds : administrationIds.split(',')) : undefined,
-        roles: roles ? (Array.isArray(roles) ? roles.filter(isRole) : roles.split(',').filter(isRole)) : undefined,
+        entreprisesIds: isNotNullNorUndefined(entrepriseIds) ? (Array.isArray(entrepriseIds) ? entrepriseIds : entrepriseIds.split(',')) : undefined,
+        administrationIds: isNotNullNorUndefined(administrationIds) ? (Array.isArray(administrationIds) ? administrationIds : administrationIds.split(',')) : undefined,
+        roles: isNotNullNorUndefined(roles) ? (Array.isArray(roles) ? roles.filter(isRole) : roles.split(',').filter(isRole)) : undefined,
         noms: noms ?? nomsUtilisateurs,
         emails,
       },
diff --git a/packages/api/src/business/utils/urls-get.test.ts b/packages/api/src/business/utils/urls-get.test.ts
index c1b55bcce..29cc026c5 100644
--- a/packages/api/src/business/utils/urls-get.test.ts
+++ b/packages/api/src/business/utils/urls-get.test.ts
@@ -4,10 +4,10 @@ import { CaminoAnnee, toCaminoAnnee } from 'camino-common/src/date.js'
 
 describe('activitesUrlGet', () => {
   test.each<[{ typesIds?: string[]; statutsIds?: string[]; annees?: CaminoAnnee[] } | undefined, string]>([
-    [undefined, 'https://camino.beta.gouv.fr/activites?page=1&intervalle=200&ordre=asc'],
-    [{ typesIds: ['toto'] }, 'https://camino.beta.gouv.fr/activites?page=1&intervalle=200&ordre=asc&typesIds=toto'],
-    [{ typesIds: ['toto', 'tata'] }, 'https://camino.beta.gouv.fr/activites?page=1&intervalle=200&ordre=asc&typesIds=toto%2Ctata'],
-    [{ typesIds: ['toto'], annees: [toCaminoAnnee(2010)] }, 'https://camino.beta.gouv.fr/activites?page=1&intervalle=200&ordre=asc&typesIds=toto&annees=2010'],
+    [undefined, 'http://plop.plop/activites?page=1&intervalle=200&ordre=asc'],
+    [{ typesIds: ['toto'] }, 'http://plop.plop/activites?page=1&intervalle=200&ordre=asc&typesIds=toto'],
+    [{ typesIds: ['toto', 'tata'] }, 'http://plop.plop/activites?page=1&intervalle=200&ordre=asc&typesIds=toto%2Ctata'],
+    [{ typesIds: ['toto'], annees: [toCaminoAnnee(2010)] }, 'http://plop.plop/activites?page=1&intervalle=200&ordre=asc&typesIds=toto&annees=2010'],
   ])('test la construction de l url des activités', (params, url) => {
     expect(activitesUrlGet(params)).toEqual(url)
   })
diff --git a/packages/api/src/business/utils/urls-get.ts b/packages/api/src/business/utils/urls-get.ts
index 2d31b905d..f84941f44 100644
--- a/packages/api/src/business/utils/urls-get.ts
+++ b/packages/api/src/business/utils/urls-get.ts
@@ -1,9 +1,10 @@
 import { CaminoAnnee } from 'camino-common/src/date.js'
+import { config } from '../../config/index.js'
 
-export const titreUrlGet = (titreId: string) => `${process.env.OAUTH_URL}/titres/${titreId}`
+export const titreUrlGet = (titreId: string) => `${config().OAUTH_URL}/titres/${titreId}`
 
 export const activitesUrlGet = (params?: { typesIds?: string[]; statutsIds?: string[]; annees?: CaminoAnnee[] }): string => {
-  const url = new URL(`${process.env.OAUTH_URL ?? 'https://camino.beta.gouv.fr'}/activites`)
+  const url = new URL(`${config().OAUTH_URL}/activites`)
 
   url.searchParams.append('page', '1')
   url.searchParams.append('intervalle', '200')
diff --git a/packages/api/src/config/index.ts b/packages/api/src/config/index.ts
index f75391c46..dfe90b47a 100644
--- a/packages/api/src/config/index.ts
+++ b/packages/api/src/config/index.ts
@@ -1,4 +1,59 @@
-const port = Number(process.env.API_PORT)
-const url = `http://localhost:${port}/`
+import dotenv from 'dotenv'
+import { resolve } from 'node:path'
+import { caminoAnneeValidator, getCurrentAnnee } from 'camino-common/src/date.js'
+import { caminoConfigValidator } from 'camino-common/src/static/config.js'
+import { isNullOrUndefined } from 'camino-common/src/typescript-tools.js'
+import { z } from 'zod'
 
-export { port, url }
+dotenv.config({ path: resolve(process.cwd(), '../../.env') })
+
+const JWT_ALGORITHMS = ['HS256', 'HS384', 'HS512', 'RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'PS256', 'PS384', 'PS512', 'none'] as const
+
+const configValidator = caminoConfigValidator.extend({
+  API_HOST: z.string(),
+  KEYCLOAK_API_CLIENT_ID: z.string(),
+  KEYCLOAK_API_CLIENT_SECRET: z.string(),
+  KEYCLOAK_URL: z.string().url(),
+  KEYCLOAK_RESET_PASSWORD_URL: z.string(),
+  KEYCLOAK_CLIENT_ID: z.string(),
+  KEYCLOAK_LOGOUT_URL: z.string(),
+  OAUTH_URL: z.string().url(),
+  NODE_ENV: z.string(),
+  APPLICATION_VERSION: z.string(),
+  API_SENTRY_URL: z.string().optional(),
+  PGHOST: z.string(),
+  PGPORT: z.coerce.number().default(5432),
+  PGUSER: z.string(),
+  PGPASSWORD: z.string(),
+  PGDATABASE: z.string(),
+  API_PORT: z.coerce.number(),
+  ENV: z.enum(['prod', 'preprod', 'dev']).default('dev'),
+  ADMIN_EMAIL: z.string().email(),
+  ANNEE: caminoAnneeValidator.default(getCurrentAnnee()),
+  JWT_SECRET: z.string(),
+  JWT_SECRET_ALGORITHM: z.enum(JWT_ALGORITHMS).default('HS256'),
+  API_INSEE_URL: z.string(),
+  API_INSEE_KEY: z.string(),
+  API_INSEE_SECRET: z.string(),
+  API_MAILJET_EMAIL: z.string().email(),
+  API_MAILJET_REPLY_TO_EMAIL: z.string().email(),
+  API_ADMINISTRATION_URL: z.string().url(),
+  API_MAILJET_KEY: z.string(),
+  API_MAILJET_SECRET: z.string(),
+  API_MAILJET_CONTACTS_LIST_ID: z.coerce.number(),
+  API_MAILJET_EXPLOITANTS_GUYANE_LIST_ID: z.coerce.number(),
+  API_OPENFISCA_URL: z.string().url(),
+})
+
+let cacheConfig: z.infer<typeof configValidator>
+export const renewConfig = () => {
+  cacheConfig = configValidator.parse(process.env)
+}
+
+export const config = (): z.infer<typeof configValidator> => {
+  if (isNullOrUndefined(cacheConfig)) {
+    renewConfig()
+  }
+
+  return cacheConfig
+}
diff --git a/packages/api/src/database/init.ts b/packages/api/src/database/init.ts
index 057dc5a81..a29a8080c 100644
--- a/packages/api/src/database/init.ts
+++ b/packages/api/src/database/init.ts
@@ -1,10 +1,12 @@
 import { knex } from '../knex.js'
 import { daily } from '../business/daily.js'
 import type { Pool } from 'pg'
+import { config } from '../config/index.js'
+import { isNotNullNorUndefined } from 'camino-common/src/typescript-tools.js'
 
 export const databaseInit = async (pool: Pool) => {
   await knex.migrate.latest()
-  if (process.env.CAMINO_STAGE) {
+  if (isNotNullNorUndefined(config().CAMINO_STAGE)) {
     // pas de await pour ne pas bloquer le démarrage de l’appli
     daily(pool)
   }
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index 87c23783e..8d2b2501f 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -15,7 +15,7 @@ import express from 'express'
 import rateLimit from 'express-rate-limit'
 import * as Sentry from '@sentry/node'
 
-import { port, url } from './config/index.js'
+import { config } from './config/index.js'
 import { restWithPool } from './server/rest.js'
 import { graphql } from './server/graphql.js'
 import { authJwt } from './server/auth-jwt.js'
@@ -30,12 +30,13 @@ import { connectedCatcher } from './server/connected-catcher.js'
 import cookieParser from 'cookie-parser'
 import pg from 'pg'
 import qs from 'qs'
+import { isNotNullNorUndefined } from 'camino-common/src/typescript-tools.js'
 // Le pool ne doit être qu'aux entrypoints : le daily, le monthly, et l'application.
 const pool = new pg.Pool({
-  host: process.env.PGHOST,
-  user: process.env.PGUSER,
-  password: process.env.PGPASSWORD,
-  database: process.env.PGDATABASE,
+  host: config().PGHOST,
+  user: config().PGUSER,
+  password: config().PGPASSWORD,
+  database: config().PGDATABASE,
   idleTimeoutMillis: 60000,
 })
 
@@ -47,10 +48,10 @@ databaseInit(pool).then(() => {
   app.set('query parser', function (str: string) {
     return qs.parse(str, { comma: true })
   })
-  if (process.env.API_SENTRY_URL) {
+  if (isNotNullNorUndefined(config().API_SENTRY_URL)) {
     Sentry.init({
-      dsn: process.env.API_SENTRY_URL,
-      environment: process.env.ENV === 'prod' ? 'production' : process.env.ENV,
+      dsn: config().API_SENTRY_URL,
+      environment: config().ENV === 'prod' ? 'production' : config().ENV,
     })
     app.use(Sentry.Handlers.requestHandler())
   }
@@ -80,7 +81,7 @@ databaseInit(pool).then(() => {
     res.writeHead(200, headers)
     res.write(`id: ${Date.now()}\n`)
     res.write(`event: version\n`)
-    res.write(`data: ${process.env.APPLICATION_VERSION}\n\n`)
+    res.write(`data: ${config().APPLICATION_VERSION}\n\n`)
     res.flush()
     let counter = 0
     const interValID = setInterval(() => {
@@ -93,7 +94,7 @@ databaseInit(pool).then(() => {
       }
       res.write(`id: ${Date.now()}\n`)
       res.write(`event: version\n`)
-      res.write(`data: ${process.env.APPLICATION_VERSION}\n\n`)
+      res.write(`data: ${config().APPLICATION_VERSION}\n\n`)
       res.flush()
     }, ssePingDelayInSeconds * 1000)
 
@@ -109,19 +110,14 @@ databaseInit(pool).then(() => {
 
   app.use('/', graphqlUpload, graphql(pool))
 
-  if (process.env.API_SENTRY_URL) {
+  if (isNotNullNorUndefined(config().API_SENTRY_URL)) {
     app.use(Sentry.Handlers.errorHandler())
   }
 
-  app.listen(port, () => {
+  app.listen(config().API_PORT, () => {
     console.info('')
-    console.info('URL:', url)
-    console.info('ENV:', process.env.ENV)
-    console.info('NODE_ENV:', process.env.NODE_ENV)
-
-    if (process.env.NODE_DEBUG === 'true') {
-      console.warn('NODE_DEBUG:', process.env.NODE_DEBUG)
-    }
+    console.info('ENV:', config().ENV)
+    console.info('NODE_ENV:', config().NODE_ENV)
     console.info('')
   })
 })
diff --git a/packages/api/src/init.ts b/packages/api/src/init.ts
index 96445e1d4..4cc3abb1f 100644
--- a/packages/api/src/init.ts
+++ b/packages/api/src/init.ts
@@ -1,8 +1,4 @@
-import dotenv from 'dotenv'
-import { resolve } from 'path'
 import { knexInit } from './knex.js'
 import { knexConfig } from './knex/config.js'
 
-dotenv.config({ path: resolve(process.cwd(), '../../.env') })
-
 knexInit(knexConfig)
diff --git a/packages/api/src/knex/config.ts b/packages/api/src/knex/config.ts
index 5a2343fad..5f530efd0 100644
--- a/packages/api/src/knex/config.ts
+++ b/packages/api/src/knex/config.ts
@@ -2,14 +2,15 @@ import 'dotenv/config'
 import { knexSnakeCaseMappers } from 'objection'
 import path, { join } from 'node:path'
 import { fileURLToPath } from 'url'
+import { config } from '../config/index.js'
 const __dirname = path.dirname(fileURLToPath(import.meta.url))
 
 const connection = {
-  host: process.env.PGHOST,
-  port: Number(process.env.PGPORT),
-  database: process.env.PGDATABASE,
-  user: process.env.PGUSER,
-  password: process.env.PGPASSWORD,
+  host: config().PGHOST,
+  port: config().PGPORT,
+  database: config().PGDATABASE,
+  user: config().PGUSER,
+  password: config().PGPASSWORD,
 }
 
 export const simpleKnexConfig = {
diff --git a/packages/api/src/scripts/daily.ts b/packages/api/src/scripts/daily.ts
index 9f58a40db..d1bfcca5a 100644
--- a/packages/api/src/scripts/daily.ts
+++ b/packages/api/src/scripts/daily.ts
@@ -7,11 +7,13 @@ import { readFileSync, writeFileSync, createWriteStream } from 'fs'
 import { documentsClean } from '../tools/documents/clean.js'
 import * as Console from 'console'
 import pg from 'pg'
+import { config } from '../config/index.js'
+import { isNotNullNorUndefined } from 'camino-common/src/typescript-tools.js'
 
 const logFile = '/tmp/cron.log'
 const output = createWriteStream(logFile)
 
-if (process.env.CAMINO_STAGE) {
+if (isNotNullNorUndefined(config().CAMINO_STAGE)) {
   const logger = new Console.Console({ stdout: output, stderr: output })
   // eslint-disable-next-line no-console
   console.log = logger.log
@@ -20,10 +22,10 @@ if (process.env.CAMINO_STAGE) {
 
 // Le pool ne doit être qu'aux entrypoints : le daily, le monthly, et l'application.
 const pool = new pg.Pool({
-  host: process.env.PGHOST,
-  user: process.env.PGUSER,
-  password: process.env.PGPASSWORD,
-  database: process.env.PGDATABASE,
+  host: config().PGHOST,
+  user: config().PGUSER,
+  password: config().PGPASSWORD,
+  database: config().PGDATABASE,
 })
 
 const tasks = async () => {
@@ -32,7 +34,7 @@ const tasks = async () => {
   writeFileSync(logFile, '')
   try {
     await daily(pool)
-    if (process.env.CAMINO_STAGE) {
+    if (isNotNullNorUndefined(config().CAMINO_STAGE)) {
       await documentsClean(pool)
       await documentsCheck(pool)
     }
@@ -40,10 +42,10 @@ const tasks = async () => {
     console.error('Erreur durant le daily', e)
   }
 
-  if (process.env.CAMINO_STAGE) {
-    const emailBody = `Résultats de ${process.env.ENV} \n${readFileSync(logFile).toString()}`
-    await mailjetSend([process.env.ADMIN_EMAIL!], {
-      Subject: `[Camino][${process.env.ENV}] Résultats du daily`,
+  if (isNotNullNorUndefined(config().CAMINO_STAGE)) {
+    const emailBody = `Résultats de ${config().ENV} \n${readFileSync(logFile).toString()}`
+    await mailjetSend([config().ADMIN_EMAIL], {
+      Subject: `[Camino][${config().ENV}] Résultats du daily`,
       'Text-part': emailBody,
     })
   }
diff --git a/packages/api/src/scripts/keycloak-migration.ts b/packages/api/src/scripts/keycloak-migration.ts
index 2799e2eca..1c1a20354 100644
--- a/packages/api/src/scripts/keycloak-migration.ts
+++ b/packages/api/src/scripts/keycloak-migration.ts
@@ -1,10 +1,11 @@
 import { getKeycloakApiToken } from '../api/rest/utilisateurs.js'
+import { config } from '../config/index.js'
 import '../init.js'
 import { knex } from '../knex.js'
 
 const migrate = async (): Promise<void> => {
   const token = await getKeycloakApiToken()
-  const url = process.env.KEYCLOAK_URL
+  const url = config().KEYCLOAK_URL
 
   if (!url) {
     throw new Error('variables KEYCLOAK_API_CLIENT_ID and KEYCLOAK_API_CLIENT_SECRET and KEYCLOAK_URL must be set')
diff --git a/packages/api/src/scripts/matrices.ts b/packages/api/src/scripts/matrices.ts
index 3184ed13a..4c0b07695 100644
--- a/packages/api/src/scripts/matrices.ts
+++ b/packages/api/src/scripts/matrices.ts
@@ -1,16 +1,15 @@
-import { getCurrentAnnee, toCaminoAnnee } from 'camino-common/src/date.js'
 import { matrices } from '../business/matrices.js'
 import pg from 'pg'
-import { isNotNullNorUndefined } from 'camino-common/src/typescript-tools.js'
+import { config } from '../config/index.js'
 // Le pool ne doit être qu'aux entrypoints : le daily, le monthly, et l'application.
 const pool = new pg.Pool({
-  host: process.env.PGHOST,
-  user: process.env.PGUSER,
-  password: process.env.PGPASSWORD,
-  database: process.env.PGDATABASE,
+  host: config().PGHOST,
+  user: config().PGUSER,
+  password: config().PGPASSWORD,
+  database: config().PGDATABASE,
 })
 
-matrices(isNotNullNorUndefined(process.env.ANNEE) ? toCaminoAnnee(process.env.ANNEE) : getCurrentAnnee(), pool)
+matrices(config().ANNEE, pool)
   .then(() => {
     process.exit()
   })
diff --git a/packages/api/src/scripts/monthly.ts b/packages/api/src/scripts/monthly.ts
index 22ba514eb..366ac2f7c 100644
--- a/packages/api/src/scripts/monthly.ts
+++ b/packages/api/src/scripts/monthly.ts
@@ -5,19 +5,21 @@ import { readFileSync, writeFileSync, createWriteStream } from 'fs'
 import * as Console from 'console'
 import { monthly } from '../business/monthly.js'
 import pg from 'pg'
+import { config } from '../config/index.js'
+import { isNotNullNorUndefined } from 'camino-common/src/typescript-tools.js'
 
 const logFile = '/tmp/monthly.log'
 
 // Le pool ne doit être qu'aux entrypoints : le daily, le monthly, et l'application.
 const pool = new pg.Pool({
-  host: process.env.PGHOST,
-  user: process.env.PGUSER,
-  password: process.env.PGPASSWORD,
-  database: process.env.PGDATABASE,
+  host: config().PGHOST,
+  user: config().PGUSER,
+  password: config().PGPASSWORD,
+  database: config().PGDATABASE,
 })
 
 const output = createWriteStream(logFile)
-if (process.env.CAMINO_STAGE) {
+if (isNotNullNorUndefined(config().CAMINO_STAGE)) {
   const logger = new Console.Console({ stdout: output, stderr: output })
   // eslint-disable-next-line no-console
   console.log = logger.log
@@ -34,10 +36,10 @@ const tasks = async () => {
     console.error('Erreur durant le monthly', e)
   }
 
-  if (process.env.CAMINO_STAGE) {
-    const emailBody = `Résultats de ${process.env.ENV} \n${readFileSync(logFile).toString()}`
-    await mailjetSend([process.env.ADMIN_EMAIL!], {
-      Subject: `[Camino][${process.env.ENV}] Résultats du monthly`,
+  if (isNotNullNorUndefined(config().CAMINO_STAGE)) {
+    const emailBody = `Résultats de ${config().ENV} \n${readFileSync(logFile).toString()}`
+    await mailjetSend([config().ADMIN_EMAIL], {
+      Subject: `[Camino][${config().ENV}] Résultats du monthly`,
       'Text-part': emailBody,
     })
   }
diff --git a/packages/api/src/server/auth-jwt.ts b/packages/api/src/server/auth-jwt.ts
index d2e43da08..7c1b000b1 100644
--- a/packages/api/src/server/auth-jwt.ts
+++ b/packages/api/src/server/auth-jwt.ts
@@ -1,20 +1,20 @@
-import { Algorithm } from 'jsonwebtoken'
 import { expressjwt } from 'express-jwt'
 import { CaminoRequest } from '../api/rest/express-type'
+import { config } from '../config/index.js'
+import { isNotNullNorUndefined } from 'camino-common/src/typescript-tools.js'
 
 export const authJwt = expressjwt({
   credentialsRequired: false,
   getToken: (req: CaminoRequest) => {
-    if (req.headers) {
+    if (isNotNullNorUndefined(req.headers)) {
       const token = req.headers['x-forwarded-access-token']
-      if (token) {
+      if (isNotNullNorUndefined(token)) {
         return Array.isArray(token) ? token[0] : token
       }
     }
 
     return undefined
   },
-  secret: process.env.JWT_SECRET || 'jwtSecret should be declared in .env',
-  // TODO 2023-03-15: vérifier ça au runtime ?
-  algorithms: [(process.env.JWT_SECRET_ALGORITHM as Algorithm) || 'HS256'],
+  secret: config().JWT_SECRET,
+  algorithms: [config().JWT_SECRET_ALGORITHM],
 })
diff --git a/packages/api/src/server/config.test.integration.ts b/packages/api/src/server/config.test.integration.ts
index 1d178abd3..261ffc63b 100644
--- a/packages/api/src/server/config.test.integration.ts
+++ b/packages/api/src/server/config.test.integration.ts
@@ -10,7 +10,9 @@ describe('config', () => {
     const tested = await restCall(null as unknown as Pool, '/config', {}, undefined)
     expect(tested.body).toMatchInlineSnapshot(`
       {
-        "environment": "dev",
+        "API_MATOMO_ID": "plop",
+        "API_MATOMO_URL": "plop",
+        "SENTRY_DSN": "plop",
       }
     `)
   })
diff --git a/packages/api/src/server/rest.ts b/packages/api/src/server/rest.ts
index cae62ae83..31375be77 100644
--- a/packages/api/src/server/rest.ts
+++ b/packages/api/src/server/rest.ts
@@ -51,6 +51,7 @@ import { geojsonImport, geojsonImportPoints, convertGeojsonPointsToGeoSystemeId,
 import { getDataGouvStats } from '../api/rest/statistiques/datagouv.js'
 import { addAdministrationActiviteTypeEmails, deleteAdministrationActiviteTypeEmails, getAdministrationActiviteTypeEmails, getAdministrationUtilisateurs } from '../api/rest/administrations.js'
 import { titreDemandeCreer } from '../api/rest/titre-demande.js'
+import { config } from '../config/index.js'
 
 interface IRestResolverResult {
   nom: string
@@ -84,16 +85,15 @@ type Transform<Route> = (Route extends GetRestRoutes ? { get: RestGetCall<Route>
   (Route extends NewDownloadRestRoutes ? { newDownload: NewDownload } : {}) &
   (Route extends DownloadRestRoutes ? { download: RestDownloadCall } : {})
 
-const config = (_pool: Pool) => async (_req: CaminoRequest, res: CustomResponse<CaminoConfig>) => {
-  const config: CaminoConfig = {
-    sentryDsn: process.env.SENTRY_DSN,
-    caminoStage: process.env.CAMINO_STAGE,
-    environment: process.env.ENV ?? 'dev',
-    matomoHost: process.env.API_MATOMO_URL,
-    matomoSiteId: process.env.API_MATOMO_ID,
+const getConfig = (_pool: Pool) => async (_req: CaminoRequest, res: CustomResponse<CaminoConfig>) => {
+  const caminoConfig: CaminoConfig = {
+    CAMINO_STAGE: config().CAMINO_STAGE,
+    SENTRY_DSN: config().SENTRY_DSN,
+    API_MATOMO_URL: config().API_MATOMO_URL,
+    API_MATOMO_ID: config().API_MATOMO_ID,
   }
 
-  res.json(caminoConfigValidator.parse(config))
+  res.json(caminoConfigValidator.parse(caminoConfig))
 }
 
 const restRouteImplementations: Readonly<{ [key in CaminoRestRoute]: Transform<key> }> = {
@@ -115,7 +115,7 @@ const restRouteImplementations: Readonly<{ [key in CaminoRestRoute]: Transform<k
   // NE PAS TOUCHER A CES ROUTES, ELLES SONT UTILISÉES HORS UI
 
   '/moi': { get: moi },
-  '/config': { get: config },
+  '/config': { get: getConfig },
   '/rest/titres/:id/titreLiaisons': { get: getTitreLiaisons, post: postTitreLiaisons },
   '/rest/etapesTypes/:demarcheId/:date': { get: getEtapesTypesEtapesStatusWithMainStep },
   '/rest/titres': { post: titreDemandeCreer },
@@ -165,24 +165,30 @@ export const restWithPool = (dbPool: Pool) => {
     .forEach(route => {
       const maRoute = restRouteImplementations[route]
       if ('get' in maRoute) {
+        console.info(`GET ${route}`)
         rest.get(route, restCatcher(maRoute.get(dbPool)))
       }
       if ('post' in maRoute) {
+        console.info(`POST ${route}`)
         rest.post(route, restCatcher(maRoute.post(dbPool)))
       }
       if ('put' in maRoute) {
+        console.info(`PUT ${route}`)
         rest.put(route, restCatcher(maRoute.put(dbPool)))
       }
 
       if ('delete' in maRoute) {
+        console.info(`delete ${route}`)
         rest.delete(route, restCatcher(maRoute.delete(dbPool)))
       }
 
       if ('download' in maRoute) {
+        console.info(`download ${route}`)
         rest.get(route, restDownload(maRoute.download(dbPool)))
       }
 
       if ('newDownload' in maRoute) {
+        console.info(`newDownload ${route}`)
         rest.get(route, restNewDownload(dbPool, maRoute.newDownload))
       }
     })
diff --git a/packages/api/src/server/user-loader.ts b/packages/api/src/server/user-loader.ts
index 73b3c9be3..0c7d11022 100644
--- a/packages/api/src/server/user-loader.ts
+++ b/packages/api/src/server/user-loader.ts
@@ -8,6 +8,7 @@ import { formatUser } from '../types.js'
 import { getCurrent } from 'camino-common/src/date.js'
 import { EmailTemplateId } from '../tools/api-mailjet/types.js'
 import { isNotNullNorUndefined, isNullOrUndefined } from 'camino-common/src/typescript-tools.js'
+import { config } from '../config/index.js'
 
 export type JWTUser = { email?: string; family_name?: string; given_name?: string; sub: string | undefined }
 export const userLoader = async (req: JWTRequest<{ email?: string; family_name?: string; given_name?: string; sub?: string }>, _res: express.Response, next: express.NextFunction) => {
@@ -37,9 +38,9 @@ export const userLoader = async (req: JWTRequest<{ email?: string; family_name?:
         )
 
         await emailsSend(
-          [process.env.ADMIN_EMAIL!],
+          [config().ADMIN_EMAIL],
           `Nouvel utilisateur ${user.email} créé`,
-          `L'utilisateur ${user.nom} ${user.prenom} vient de se créer un compte : ${process.env.OAUTH_URL}/utilisateurs/${user.id}`
+          `L'utilisateur ${user.nom} ${user.prenom} vient de se créer un compte : ${config().OAUTH_URL}/utilisateurs/${user.id}`
         )
         if (isNotNullNorUndefined(reqUser.email)) {
           await emailsWithTemplateSend([reqUser.email], EmailTemplateId.CREATION_COMPTE, {})
diff --git a/packages/api/src/tools/api-administrations/index.ts b/packages/api/src/tools/api-administrations/index.ts
index b6a0a8af0..7e805d3ba 100644
--- a/packages/api/src/tools/api-administrations/index.ts
+++ b/packages/api/src/tools/api-administrations/index.ts
@@ -2,10 +2,12 @@
 import errorLog from '../error-log.js'
 import { DepartementId } from 'camino-common/src/static/departement.js'
 import { Administration, AdministrationId, AdministrationTypeId } from 'camino-common/src/static/administrations.js'
+import { config } from '../../config/index.js'
+import { isNotNullNorUndefined } from 'camino-common/src/typescript-tools.js'
 
 const MAX_CALLS_MINUTE = 200
 
-const { API_ADMINISTRATION_URL } = process.env
+const API_ADMINISTRATION_URL = config().API_ADMINISTRATION_URL
 
 interface IOrganisme {
   features: {
@@ -26,10 +28,6 @@ interface IOrganisme {
 }
 
 const organismeFetch = async (departementId: string, nom: 'paris_ppp' | 'prefecture') => {
-  if (!API_ADMINISTRATION_URL) {
-    throw new Error("impossible de se connecter à l'API administration car la variable d'environnement est absente")
-  }
-
   console.info(`API administration: requête ${departementId}, ${nom}`)
 
   const response = await fetch(`${API_ADMINISTRATION_URL}/v3/departements/${departementId}/${nom}`, {
@@ -56,7 +54,7 @@ const organismeDepartementCall = async (departementId: string, nom: 'paris_ppp'
   try {
     return await organismeFetch(departementId, nom)
   } catch (err: any) {
-    const error = err.error ? `${err.error}: ${err.error_description}` : err
+    const error = isNotNullNorUndefined(err.error) ? `${err.error}: ${err.error_description}` : err
     errorLog(`API administrations ${departementId} ${nom}:`, error)
 
     return null
@@ -92,16 +90,16 @@ const organismeFormat = (e: IOrganisme, departementId: DepartementId) => {
     telephone: p.telephone,
     departementId,
   }
-  const adresse2 = adresseB ? adresseB.lignes.join(', ') : null
-  if (adresse2) {
+  const adresse2 = isNotNullNorUndefined(adresseB) ? adresseB.lignes.join(', ') : null
+  if (isNotNullNorUndefined(adresse2)) {
     organisme.adresse2 = adresse2
   }
   const email = p.email && p.email.match('@') ? p.email : null
-  if (email) {
+  if (isNotNullNorUndefined(email)) {
     organisme.email = email
   }
   const url = p.url || null
-  if (url) {
+  if (isNotNullNorUndefined(url)) {
     organisme.url = url
   }
 
diff --git a/packages/api/src/tools/api-insee/fetch.ts b/packages/api/src/tools/api-insee/fetch.ts
index 45b2f2826..3904e5f77 100644
--- a/packages/api/src/tools/api-insee/fetch.ts
+++ b/packages/api/src/tools/api-insee/fetch.ts
@@ -5,6 +5,8 @@ import { IApiSirenQueryTypes, IApiSirenQueryToken, IApiSirenEtablissement, IApiS
 import errorLog from '../error-log.js'
 import { CaminoDate, dateAddDays, daysBetween, getCurrent } from 'camino-common/src/date.js'
 import { Siren } from 'camino-common/src/entreprise.js'
+import { config } from '../../config/index.js'
+import { isNotNullNorUndefinedNorEmpty, isNullOrUndefined } from 'camino-common/src/typescript-tools.js'
 
 const MAX_CALLS_MINUTE = 30
 const MAX_RESULTS = 20
@@ -13,7 +15,7 @@ const TOKEN_VALIDITY_IN_DAYS = 1
 // utilise `tokenInitialize` pour l'initialiser
 let apiToken: { validUntil: CaminoDate; token: string } | null = null
 
-const { API_INSEE_URL, API_INSEE_KEY, API_INSEE_SECRET } = process.env
+const { API_INSEE_URL, API_INSEE_KEY, API_INSEE_SECRET } = config()
 
 // VisibleForTesting
 export const tokenInitialize = async (tokenFetchCall = tokenFetch, today = getCurrent()): Promise<string> => {
@@ -34,6 +36,7 @@ export const tokenInitialize = async (tokenFetchCall = tokenFetch, today = getCu
   } catch (e: any) {
     errorLog(
       "API Insee: impossible de générer le token de l'API INSEE ",
+      // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
       (e.header && e.header.message) || (e.fault && `${e.fault.message}: ${e.fault.description}`) || (e.error && `${e.error}: ${e.error_description}`) || e.message || e
     )
 
@@ -43,11 +46,7 @@ export const tokenInitialize = async (tokenFetchCall = tokenFetch, today = getCu
 
 const tokenFetch = async (): Promise<IApiSirenQueryToken | null> => {
   try {
-    if (!API_INSEE_URL) {
-      throw new Error("impossible de se connecter car la variable d'environnement est absente")
-    }
-
-    console.info(`API Insee: récupération du token ${API_INSEE_KEY?.substring(0, 5)}...:${API_INSEE_SECRET?.substring(0, 5)}...`)
+    console.info(`API Insee: récupération du token ${API_INSEE_KEY.substring(0, 5)}...:${API_INSEE_SECRET.substring(0, 5)}...`)
 
     const auth = Buffer.from(`${API_INSEE_KEY}:${API_INSEE_SECRET}`).toString('base64')
 
@@ -67,7 +66,7 @@ const tokenFetch = async (): Promise<IApiSirenQueryToken | null> => {
       throw result
     }
 
-    if (!result) {
+    if (isNullOrUndefined(result)) {
       throw new Error('contenu de la réponse vide')
     }
 
@@ -75,6 +74,7 @@ const tokenFetch = async (): Promise<IApiSirenQueryToken | null> => {
   } catch (e: any) {
     errorLog(
       `API Insee: tokenFetch `,
+      // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
       (e.header && e.header.message) || (e.fault && `${e.fault.message}: ${e.fault.description}`) || (e.error && `${e.error}: ${e.error_description}`) || e.message || e
     )
 
@@ -105,7 +105,7 @@ const typeFetch = async (type: 'siren' | 'siret', q: string) => {
       throw result
     }
 
-    if (!result) {
+    if (isNullOrUndefined(result)) {
       throw new Error('API Insee: contenu de la réponse vide')
     }
 
@@ -117,6 +117,7 @@ const typeFetch = async (type: 'siren' | 'siret', q: string) => {
   } catch (e: any) {
     errorLog(
       `API Insee: typeFetch `,
+      // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
       (e.header && e.header.message) || (e.fault && `${e.fault.message}: ${e.fault.description}`) || (e.error && `${e.error}: ${e.error_description}`) || e.message || e
     )
 
@@ -132,6 +133,7 @@ const typeMultiFetch = async (type: 'siren' | 'siret', field: 'etablissements' |
   } catch (e: any) {
     errorLog(
       `API Insee: ${type} get ${ids.join(', ')}`,
+      // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
       JSON.stringify((e.header && e.header.message) || (e.fault && `${e.fault.message}: ${e.fault.description}`) || (e.error && `${e.error}: ${e.error_description}`) || e.message || e)
     )
 
@@ -155,7 +157,7 @@ export const entreprisesEtablissementsFetch = async (ids: Siren[]) => {
   const results = []
   for (const batch of batches) {
     const result = (await typeMultiFetch('siren', 'unitesLegales', batch, queryFormat(batch))) as IApiSirenUniteLegale[]
-    if (result) {
+    if (isNotNullNorUndefinedNorEmpty(result)) {
       results.push(...result)
     }
   }
@@ -175,7 +177,7 @@ export const entreprisesFetch = async (ids: Siren[]) => {
   const results = []
   for (const batch of batches) {
     const result = (await typeMultiFetch('siret', 'etablissements', batch, queryFormat(batch))) as IApiSirenEtablissement[]
-    if (result) {
+    if (isNotNullNorUndefinedNorEmpty(result)) {
       results.push(...result)
     }
   }
diff --git a/packages/api/src/tools/api-mailjet/emails.ts b/packages/api/src/tools/api-mailjet/emails.ts
index 037cb271b..ac9b0e8e9 100644
--- a/packages/api/src/tools/api-mailjet/emails.ts
+++ b/packages/api/src/tools/api-mailjet/emails.ts
@@ -2,9 +2,10 @@ import { convert } from 'html-to-text'
 import { mailjet } from './index.js'
 import { EmailTemplateId } from './types.js'
 import { emailCheck } from '../email-check.js'
+import { config } from '../../config/index.js'
 
 const from = {
-  email: process.env.API_MAILJET_EMAIL,
+  email: config().API_MAILJET_EMAIL,
   name: 'Camino - le cadastre minier',
 }
 
@@ -22,8 +23,8 @@ export const mailjetSend = async (emails: string[], options: Record<string, any>
 
     // si on est pas sur le serveur de prod
     // l'adresse email du destinataire est remplacée
-    if (process.env.NODE_ENV !== 'production' || process.env.ENV !== 'prod') {
-      emails = [process.env.ADMIN_EMAIL!]
+    if (config().NODE_ENV !== 'production' || config().ENV !== 'prod') {
+      emails = [config().ADMIN_EMAIL!]
     }
 
     const res = (await mailjet.post('send', { version: 'v3' }).request({
@@ -34,6 +35,7 @@ export const mailjetSend = async (emails: string[], options: Record<string, any>
           FromName: from.name,
           Recipients: emails.map(Email => ({ Email })),
           ...options,
+          Headers: { 'Reply-To': config().API_MAILJET_REPLY_TO_EMAIL },
         },
       ],
     })) as {
@@ -50,8 +52,8 @@ export const mailjetSend = async (emails: string[], options: Record<string, any>
 }
 
 export const emailsSend = async (emails: string[], subject: string, html: string) => {
-  if (process.env.NODE_ENV !== 'production' || process.env.ENV !== 'prod') {
-    html = `<p style="color: red">destinataire(s): ${emails.join(', ')} | env: ${process.env.ENV} | node: ${process.env.NODE_ENV}</p> ${html}`
+  if (config().NODE_ENV !== 'production' || config().ENV !== 'prod') {
+    html = `<p style="color: red">destinataire(s): ${emails.join(', ')} | env: ${config().ENV} | node: ${config().NODE_ENV}</p> ${html}`
   }
 
   mailjetSend(emails, {
diff --git a/packages/api/src/tools/api-mailjet/index.ts b/packages/api/src/tools/api-mailjet/index.ts
index 3c6060faf..cc4b8a134 100644
--- a/packages/api/src/tools/api-mailjet/index.ts
+++ b/packages/api/src/tools/api-mailjet/index.ts
@@ -1,7 +1,8 @@
 import Mailjet from 'node-mailjet'
+import { config } from '../../config/index.js'
 
 // eslint-disable-next-line new-cap
 export const mailjet = new Mailjet({
-  apiKey: process.env.API_MAILJET_KEY || 'fakeKey',
-  apiSecret: process.env.API_MAILJET_SECRET || 'fakeSecret',
+  apiKey: config().API_MAILJET_KEY,
+  apiSecret: config().API_MAILJET_SECRET,
 })
diff --git a/packages/api/src/tools/api-mailjet/newsletter.ts b/packages/api/src/tools/api-mailjet/newsletter.ts
index e1b3dae5e..85413bfd3 100644
--- a/packages/api/src/tools/api-mailjet/newsletter.ts
+++ b/packages/api/src/tools/api-mailjet/newsletter.ts
@@ -1,3 +1,5 @@
+import { isNotNullNorUndefined, isNullOrUndefined } from 'camino-common/src/typescript-tools.js'
+import { config } from '../../config/index.js'
 import { mailjet } from './index.js'
 
 interface IContactListAdd {
@@ -5,15 +7,15 @@ interface IContactListAdd {
   Action: string
 }
 
-const newsLetterContactListId = Number(process.env.API_MAILJET_CONTACTS_LIST_ID!)
-const exploitantsGuyaneContactListId = Number(process.env.API_MAILJET_EXPLOITANTS_GUYANE_LIST_ID!)
+const newsLetterContactListId = config().API_MAILJET_CONTACTS_LIST_ID
+const exploitantsGuyaneContactListId = config().API_MAILJET_EXPLOITANTS_GUYANE_LIST_ID
 
 export const isSubscribedToNewsLetter = async (email: string | null | undefined): Promise<boolean> => {
   return isSubscribed(newsLetterContactListId, email)
 }
 
 const isSubscribed = async (contactListId: number, email: string | null | undefined): Promise<boolean> => {
-  if (email) {
+  if (isNotNullNorUndefined(email) && email !== '') {
     const recipientsResult = await mailjet.get('listrecipient', { version: 'v3' }).request(
       {},
       {
@@ -31,7 +33,7 @@ const isSubscribed = async (contactListId: number, email: string | null | undefi
   return false
 }
 
-const contactListSubscribe = async (email: string, contactListId: number, Action: 'addforce' | 'unsub') => {
+const contactListSubscribe = async (email: string, contactListId: number, Action: 'addforce' | 'unsub'): Promise<boolean> => {
   const contactResult = (await mailjet
     .post('contact', { version: 'v3' })
     .id(encodeURIComponent(email))
@@ -42,7 +44,7 @@ const contactListSubscribe = async (email: string, contactListId: number, Action
 
   const contactListAdded = contactResult.body.Data[0]
 
-  return !!contactListAdded
+  return isNotNullNorUndefined(contactListAdded)
 }
 
 const contactAdd = async (email: string): Promise<void> => {
@@ -50,10 +52,12 @@ const contactAdd = async (email: string): Promise<void> => {
     await mailjet.post('contact', { version: 'v3' }).request({ Email: email })
   } catch (e: any) {
     // MJ18 -> erreur mailjet contact déjà existant
-    if (!e.statusText.includes('MJ18')) {
+    const statusText: string = e?.statusText ?? ''
+    if (statusText.includes('MJ18')) {
+      console.info(`utilisateur ${email} déjà existant chez mailjet`)
+    } else {
       throw e
     }
-    console.info(`utilisateur ${email} déjà existant chez mailjet`)
   }
 }
 
@@ -76,7 +80,7 @@ export const exploitantsGuyaneSubscriberUpdate = async (users: { email: string;
 }
 
 export const newsletterSubscriberUpdate = async (email: string | undefined | null, subscribed: boolean): Promise<string> => {
-  if (!email) {
+  if (isNullOrUndefined(email) || email === '') {
     return ''
   }
   await isSubscribed(newsLetterContactListId, email)
diff --git a/packages/api/src/tools/api-openfisca/index.ts b/packages/api/src/tools/api-openfisca/index.ts
index 92cc0041f..14af035f7 100644
--- a/packages/api/src/tools/api-openfisca/index.ts
+++ b/packages/api/src/tools/api-openfisca/index.ts
@@ -1,6 +1,7 @@
 import { SubstanceFiscale, SubstanceFiscaleId, SubstancesFiscales } from 'camino-common/src/static/substancesFiscales.js'
 import { Unite, Unites } from 'camino-common/src/static/unites.js'
 import Decimal from 'decimal.js'
+import { config } from '../../config/index.js'
 type Attribute = 'surface_communale' | 'surface_communale_proportionnee' | 'taxe_guyane_brute' | 'taxe_guyane_deduction' | 'taxe_guyane' | string
 
 const openfiscaSubstanceFiscaleNom = (substanceFiscale: SubstanceFiscale): string => substanceFiscale.openFisca?.nom ?? substanceFiscale.nom
@@ -81,11 +82,7 @@ export interface OpenfiscaConstants {
 }
 
 const apiOpenfiscaFetch = async <T>(call: (apiOpenfiscaUrl: string) => Promise<Response>): Promise<T> => {
-  const apiOpenfiscaUrl = process.env.API_OPENFISCA_URL
-  if (!apiOpenfiscaUrl) {
-    throw new Error("impossible de se connecter à l'API Openfisca car la variable d'environnement est absente")
-  }
-
+  const apiOpenfiscaUrl = config().API_OPENFISCA_URL
   const response = await call(apiOpenfiscaUrl)
 
   const result = (await response.json()) as T
diff --git a/packages/api/test-env.ts b/packages/api/test-env.ts
new file mode 100644
index 000000000..e0fe62a3b
--- /dev/null
+++ b/packages/api/test-env.ts
@@ -0,0 +1,32 @@
+export const testEnv = {
+  SENTRY_DSN: 'plop',
+  API_MATOMO_URL: 'plop',
+  API_MATOMO_ID: 'plop',
+  API_HOST: 'plop',
+  KEYCLOAK_API_CLIENT_ID: 'plop',
+  KEYCLOAK_API_CLIENT_SECRET: 'plop',
+  KEYCLOAK_URL: 'http://KEYCLOAK_URL.plop',
+  KEYCLOAK_RESET_PASSWORD_URL: 'plop',
+  KEYCLOAK_CLIENT_ID: 'plop',
+  KEYCLOAK_LOGOUT_URL: 'plop',
+  OAUTH_URL: 'http://plop.plop',
+  APPLICATION_VERSION: 'plop',
+  PGHOST: 'localhost',
+  PGUSER: 'postgres',
+  PGPASSWORD: 'password',
+  PGDATABASE: 'camino_test',
+  API_PORT: '32',
+  ADMIN_EMAIL: 'plop@plop.plop',
+  JWT_SECRET: 'plop',
+  API_INSEE_URL: 'plop',
+  API_INSEE_KEY: 'plop',
+  API_INSEE_SECRET: 'plop',
+  API_MAILJET_EMAIL: 'plop@plop.plop',
+  API_MAILJET_REPLY_TO_EMAIL: 'plop@plop.plop',
+  API_ADMINISTRATION_URL: 'https://plop.plop',
+  API_MAILJET_KEY: 'plop',
+  API_MAILJET_SECRET: 'plop',
+  API_MAILJET_CONTACTS_LIST_ID: '12',
+  API_MAILJET_EXPLOITANTS_GUYANE_LIST_ID: '12',
+  API_OPENFISCA_URL: 'http://plop.plop',
+}
diff --git a/packages/api/tests/_utils/index.ts b/packages/api/tests/_utils/index.ts
index 59cef0d29..1ba1495e4 100644
--- a/packages/api/tests/_utils/index.ts
+++ b/packages/api/tests/_utils/index.ts
@@ -16,6 +16,7 @@ import { z } from 'zod'
 import { newUtilisateurId } from '../../src/database/models/_format/id-create.js'
 import { idUserKeycloakRecognised } from '../keycloak.js'
 import { isNotNullNorUndefined } from 'camino-common/src/typescript-tools.js'
+import { config } from '../../src/config/index.js'
 
 export const queryImport = (nom: string) =>
   fs
@@ -24,10 +25,7 @@ export const queryImport = (nom: string) =>
     .toString()
 
 const tokenCreate = (user: Partial<IUtilisateur>) => {
-  if (isNotNullNorUndefined(process.env.JWT_SECRET)) {
-    return jwt.sign(JSON.stringify(user), process.env.JWT_SECRET)
-  }
-  throw new Error('La variable d’environnement JWT_SECRET est manquante')
+  return jwt.sign(JSON.stringify(user), config().JWT_SECRET)
 }
 
 export const graphQLCall = async (pool: Pool, query: string, variables: Index<string | boolean | Index<string | boolean | Index<string>[] | any>>, user: TestUser | undefined) => {
diff --git a/packages/api/tests/db-manager.ts b/packages/api/tests/db-manager.ts
index b57037fca..9ae7543c6 100644
--- a/packages/api/tests/db-manager.ts
+++ b/packages/api/tests/db-manager.ts
@@ -6,6 +6,7 @@ import { knexInstanceSet } from '../src/knex.js'
 import knex, { Knex } from 'knex'
 import pg, { Client } from 'pg'
 import { knexSnakeCaseMappers, Model } from 'objection'
+import { config } from '../src/config/index.js'
 
 class DbManager {
   private readonly dbName: string
@@ -17,11 +18,11 @@ class DbManager {
   }
 
   private static getPgUser() {
-    return process.env.PGUSER ?? 'postgres'
+    return config().PGUSER
   }
 
   private static getPgPassword() {
-    return process.env.PGPASSWORD ?? 'password'
+    return config().PGPASSWORD
   }
 
   private async init(): Promise<void> {
diff --git a/packages/api/vitest.integration.config.ts b/packages/api/vitest.integration.config.ts
index 80b806cd6..30fc8ba93 100644
--- a/packages/api/vitest.integration.config.ts
+++ b/packages/api/vitest.integration.config.ts
@@ -1,5 +1,6 @@
 import { defineConfig } from 'vitest/config'
 import path from 'path'
+import { testEnv } from './test-env'
 
 export default defineConfig({
   test: {
@@ -8,5 +9,6 @@ export default defineConfig({
     setupFiles: path.resolve(__dirname, './tests/vitestSetup.ts'),
     testTimeout: 10000,
     hookTimeout: 30000,
+    env: testEnv,
   },
 })
diff --git a/packages/api/vitest.unit.config.ts b/packages/api/vitest.unit.config.ts
index 73e289018..c5d9cba9f 100644
--- a/packages/api/vitest.unit.config.ts
+++ b/packages/api/vitest.unit.config.ts
@@ -1,8 +1,10 @@
 import { defineConfig } from 'vitest/config'
 import path from 'path'
+import { testEnv } from './test-env'
 
 export default defineConfig({
   test: {
     setupFiles: path.resolve(__dirname, './tests/vitestSetup.ts'),
+    env: testEnv,
   },
 })
diff --git a/packages/common/src/static/config.ts b/packages/common/src/static/config.ts
index f0053b226..19784c550 100644
--- a/packages/common/src/static/config.ts
+++ b/packages/common/src/static/config.ts
@@ -1,11 +1,10 @@
 import { z } from 'zod'
 
 export const caminoConfigValidator = z.object({
-  caminoStage: z.enum(['dev', 'preprod', 'prod']).optional(),
-  sentryDsn: z.string().optional(),
-  environment: z.string(),
-  matomoHost: z.string().optional(),
-  matomoSiteId: z.string().optional(),
+  CAMINO_STAGE: z.enum(['prod', 'preprod', 'dev']).optional(),
+  SENTRY_DSN: z.string(),
+  API_MATOMO_URL: z.string(),
+  API_MATOMO_ID: z.string(),
 })
 
 export type CaminoConfig = z.infer<typeof caminoConfigValidator>
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index dc474a63b..ad68baeb1 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -14,6 +14,7 @@ import { initMatomo } from './stats/matomo'
 import type { User } from 'camino-common/src/roles'
 import { userKey, entreprisesKey } from './moi'
 import type { Entreprise } from 'camino-common/src/entreprise'
+import { isNotNullNorUndefined } from 'camino-common/src/typescript-tools'
 // Le Timeout du sse côté backend est mis à 30 secondes, toujours avoir une valeur plus haute ici
 const sseTimeoutInSeconds = 45
 
@@ -67,13 +68,13 @@ Promise.resolve().then(async (): Promise<void> => {
   // TODO 2024-03-04 à supprimer quand on a plus etape-edition.vue
   app.config.globalProperties.user = user
   app.config.globalProperties.entreprises = entreprises
-  if (configFromJson.caminoStage) {
+  if (isNotNullNorUndefined(configFromJson.CAMINO_STAGE)) {
     try {
-      if (!configFromJson.sentryDsn) throw new Error('dsn manquant')
+      if (!configFromJson.SENTRY_DSN) throw new Error('dsn manquant')
       Sentry.init({
         app,
-        dsn: configFromJson.sentryDsn,
-        environment: configFromJson.environment,
+        dsn: configFromJson.SENTRY_DSN,
+        environment: configFromJson.CAMINO_STAGE,
         autoSessionTracking: false,
         integrations: [
           new BrowserTracing({
@@ -88,12 +89,12 @@ Promise.resolve().then(async (): Promise<void> => {
       console.error('erreur : Sentry :', e)
     }
     try {
-      if (!configFromJson.matomoHost || !configFromJson.matomoSiteId || !configFromJson.environment) throw new Error('host et/ou siteId manquant(s)')
+      if (!configFromJson.API_MATOMO_URL || !configFromJson.API_MATOMO_ID || !configFromJson.CAMINO_STAGE) throw new Error('host et/ou siteId manquant(s)')
 
       await initMatomo({
-        host: configFromJson.matomoHost,
-        siteId: configFromJson.matomoSiteId,
-        environnement: configFromJson.environment,
+        host: configFromJson.API_MATOMO_URL,
+        siteId: configFromJson.API_MATOMO_ID,
+        environnement: configFromJson.CAMINO_STAGE,
         router,
       })
     } catch (e) {
-- 
GitLab