This commit is contained in:
Joris Bertomeu
2025-08-19 16:34:19 +02:00
commit 5c6da90f98
33 changed files with 8066 additions and 0 deletions

45
.dockerignore Normal file
View File

@@ -0,0 +1,45 @@
# Logs
logs
*.log
npm-debug.log*
# Dépendances
node_modules
npm-debug.log
yarn-debug.log
yarn-error.log
# Données
data
*.db
*.sqlite
# Système
.DS_Store
Thumbs.db
# VCS
.git
.gitignore
# Environnement
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE
.idea
.vscode
*.sublime-project
*.sublime-workspace
# Docker
Dockerfile
docker-compose.yml
.dockerignore
# Tests
coverage
.nyc_output

47
.gitignore vendored Normal file
View File

@@ -0,0 +1,47 @@
# Environment variables
#.env
# Logs
logs
*.log
npm-debug.log*
# Documentation
docs
# Runtime data
pids
*.pid
*.seed
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules
jspm_packages
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
[Uu]ploads/
dist/

31
Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
FROM node:22-alpine
# Installer PM2 globalement
RUN npm install pm2 -g
# Créer le répertoire de l'application
WORKDIR /app
# Copier les fichiers de dépendances
COPY package*.json ./
# Installer les dépendances
RUN npm install --production
# Copier les fichiers du projet
COPY . .
# Créer le répertoire de données
RUN mkdir -p /app/data && chmod 777 /app/data
# Exposer les ports (ajuster selon vos besoins)
EXPOSE 3000 2525
# Configuration des variables d'environnement
ENV NODE_ENV=production
ENV SERVER_PORT=3000
ENV SMTP_PORT=2525
ENV SMTP_HOST=0.0.0.0
# Démarrer l'application avec PM2
CMD ["pm2-runtime", "start", "ecosystem.config.js"]

67
README.md Normal file
View File

@@ -0,0 +1,67 @@
# Local Mail Notifier
Un serveur mail local qui envoie des notifications via Pushover quand des emails sont reçus.
## Structure du projet
```
local-mail-notifier/
├── config/
│ ├── default.json # Configuration par défaut
│ └── custom-environment-variables.json # Variables d'environnement
├── public/ # Fichiers statiques pour l'interface web
│ ├── css/
│ │ └── style.css
│ └── js/
│ └── main.js
├── src/
│ ├── server.js # Point d'entrée principal
│ ├── mail-server.js # Serveur SMTP
│ ├── notifiers/
│ │ └── pushover.js # Intégration Pushover
│ ├── routes/
│ │ ├── api.js # Routes API
│ │ └── web.js # Routes web pour l'interface admin
│ ├── models/
│ │ ├── email.js # Modèle pour les emails
│ │ └── settings.js # Modèle pour les paramètres
│ └── db/
│ └── database.js # Gestion de la base de données
├── views/ # Templates EJS pour l'interface web
│ ├── layouts/
│ │ └── main.ejs
│ ├── partials/
│ │ ├── header.ejs
│ │ └── footer.ejs
│ ├── dashboard.ejs
│ ├── emails.ejs
│ └── settings.ejs
└── package.json
```
## Installation
```bash
# Cloner le dépôt
git clone https://github.com/votre-username/local-mail-notifier.git
cd local-mail-notifier
# Installer les dépendances
npm install
# Démarrer le serveur
npm start
```
## Utilisation
Ce serveur offre les fonctionnalités suivantes :
- Réception d'emails sur un port SMTP configurable
- Envoi automatique de notifications via Pushover à la réception d'emails
- Interface web d'administration pour gérer les paramètres
- Visualisation des emails reçus
- Configuration des notifications (modèles, filtres, etc.)
## Configuration
Modifiez le fichier `config/default.json` selon vos besoins ou utilisez des variables d'environnement.

View File

@@ -0,0 +1,19 @@
{
"server": {
"port": "SERVER_PORT",
"sessionSecret": "SESSION_SECRET"
},
"smtp": {
"port": "SMTP_PORT",
"host": "SMTP_HOST"
},
"pushover": {
"user": "PUSHOVER_USER",
"token": "PUSHOVER_TOKEN"
},
"admin": {
"username": "ADMIN_USERNAME",
"password": "ADMIN_PASSWORD",
"enableAuth": "ENABLE_AUTH"
}
}

28
config/default.json Normal file
View File

@@ -0,0 +1,28 @@
{
"server": {
"port": 3000,
"sessionSecret": "localMailNotifierSecret"
},
"smtp": {
"port": 2525,
"host": "0.0.0.0",
"secure": false,
"authOptional": true,
"authRequired": false
},
"pushover": {
"user": "",
"token": "",
"title": "Mail Notifier",
"sound": "pushover",
"priority": 0
},
"admin": {
"username": "admin",
"password": "admin",
"enableAuth": true
},
"db": {
"path": "./data/db.json"
}
}

38
container_data/db.json Normal file
View File

@@ -0,0 +1,38 @@
{
"emails": [
{
"id": "20BOdp4orU4XryKYBAubC",
"from": "\"Test\" <test@example.com>",
"to": "\"Destinataire\" <destinataire@example.com>",
"subject": "Test de notification",
"text": "Ceci est un test d'envoi d'email vers le serveur mail local.\n\n",
"html": "",
"receivedAt": "2025-05-07T14:14:35.107Z",
"attachments": 0,
"read": false
}
],
"settings": {
"smtp": {
"port": "2525",
"host": "0.0.0.0",
"secure": false,
"authOptional": true
},
"pushover": {
"user": "u1chkifjzxpv15fcp3h56qs2gj1dup",
"token": "ah9uacr4mg5isbb86qeg2jyf35mxpk",
"title": "Mail Notifier",
"sound": "pushover",
"priority": 0
},
"admin": {
"username": "admin",
"password": "motdepasse",
"enableAuth": "true"
},
"version": "1.0.0",
"createdAt": "2025-05-07T14:13:43.742Z",
"updatedAt": "2025-05-07T14:13:43.742Z"
}
}

37
data/db.json Normal file
View File

@@ -0,0 +1,37 @@
{
"emails": [
{
"id": "kvXkk2kMjJntCMesl3yZq",
"from": "\"Test\" <test@example.com>",
"to": "\"Destinataire\" <destinataire@example.com>",
"subject": "Test de notification",
"text": "Ceci est un test d'envoi d'email vers le serveur mail local.\n\n",
"html": "",
"receivedAt": "2025-05-07T13:55:52.951Z",
"attachments": 0,
"read": true
}
],
"settings": {
"smtp": {
"port": 2525,
"host": "0.0.0.0",
"secure": false,
"authOptional": true
},
"pushover": {
"user": "u1chkifjzxpv15fcp3h56qs2gj1dup",
"token": "ah9uacr4mg5isbb86qeg2jyf35mxpk",
"title": "Mail Notifier",
"sound": "pushover",
"priority": 0
},
"admin": {
"username": "admin",
"enableAuth": true
},
"version": "1.0.0",
"createdAt": "2025-05-07T13:42:52.510Z",
"updatedAt": "2025-05-07T13:54:24.307Z"
}
}

27
docker-compose.yml Normal file
View File

@@ -0,0 +1,27 @@
version: '3.8'
services:
mail-notifier:
image: bertomlab/smtp-to-pushover
build:
context: .
dockerfile: Dockerfile
container_name: mail-notifier
restart: unless-stopped
ports:
- "3000:3000" # Port pour l'interface web
- "2525:2525" # Port pour le serveur SMTP
volumes:
- ./container_data:/app/data # Persistance des données
- ./logs:/app/logs # Logs
environment:
- NODE_ENV=production
- SERVER_PORT=3000
- SMTP_PORT=2525
- SMTP_HOST=0.0.0.0
# Configurez ici vos variables d'environnement Pushover
- PUSHOVER_USER=u1chkifjzxpv15fcp3h56qs2gj1dup
- PUSHOVER_TOKEN=ah9uacr4mg5isbb86qeg2jyf35mxpk
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=motdepasse
- ENABLE_AUTH=true

22
ecosystem.config.js Normal file
View File

@@ -0,0 +1,22 @@
module.exports = {
apps: [
{
name: 'mail-notifier',
script: 'src/server.js',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '256M',
env: {
NODE_ENV: 'production',
SERVER_PORT: 3000,
SMTP_PORT: 2525,
SMTP_HOST: '0.0.0.0'
},
log_date_format: 'YYYY-MM-DD HH:mm:ss',
error_file: '/app/logs/error.log',
out_file: '/app/logs/out.log',
merge_logs: true
}
]
};

5777
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "local-mail-notifier",
"version": "1.0.0",
"description": "Serveur mail local avec notifications Pushover et interface d'administration",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"test": "jest"
},
"keywords": [
"email",
"smtp",
"pushover",
"notifications",
"mail-server"
],
"author": "Votre Nom",
"license": "MIT",
"dependencies": {
"bcrypt": "^5.1.1",
"body-parser": "^1.20.2",
"config": "^3.3.9",
"connect-flash": "^0.1.1",
"ejs": "^3.1.9",
"express": "^4.18.2",
"express-ejs-layouts": "^2.5.1",
"express-session": "^1.17.3",
"lowdb": "^1.0.0",
"mailparser": "^3.6.5",
"method-override": "^3.0.0",
"moment": "^2.29.4",
"nanoid": "^5.1.5",
"nodemailer": "^6.9.4",
"pushover-notifications": "^1.2.2",
"smtp-server": "^3.12.0"
},
"devDependencies": {
"jest": "^29.6.4",
"nodemon": "^3.0.1"
}
}

119
public/css/style.css Normal file
View File

@@ -0,0 +1,119 @@
/* Styles personnalisés pour Mail Notifier */
/* Styles globaux */
body {
background-color: #f8f9fc;
font-family: 'Nunito', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
/* Sidebar */
.sidebar {
min-height: 100vh;
}
/* Styles pour les cartes */
.card {
margin-bottom: 24px;
border: none;
box-shadow: 0 0.15rem 1.75rem 0 rgba(58, 59, 69, 0.15);
}
.card .card-header {
background-color: #f8f9fc;
border-bottom: 1px solid #e3e6f0;
}
.card-body {
padding: 1.25rem;
}
/* Styles pour les bordures colorées */
.border-left-primary {
border-left: 0.25rem solid #4e73df !important;
}
.border-left-success {
border-left: 0.25rem solid #1cc88a !important;
}
.border-left-warning {
border-left: 0.25rem solid #f6c23e !important;
}
.border-left-danger {
border-left: 0.25rem solid #e74a3b !important;
}
/* Styles pour le tableau de bord */
.dashboard-card {
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
word-wrap: break-word;
background-color: #fff;
background-clip: border-box;
border: 1px solid #e3e6f0;
border-radius: 0.35rem;
}
/* Styles pour la page de connexion */
.login-page {
background-color: #4e73df;
background-image: linear-gradient(180deg, #4e73df 10%, #224abe 100%);
background-size: cover;
}
/* Styles pour les tableaux */
.table-responsive {
overflow-x: auto;
}
.table-hover tbody tr:hover {
background-color: rgba(0, 0, 0, 0.075);
}
/* Styles pour les boutons d'action */
.btn-group .btn {
margin-right: 0.25rem;
}
.btn-group form {
display: inline-block;
}
/* Styles pour le contenu des emails */
.email-content-html {
border: 1px solid #e3e6f0;
border-radius: 0.35rem;
overflow: hidden;
}
.text-content {
font-family: monospace;
white-space: pre-wrap;
background-color: #f8f9fc;
border-radius: 0.35rem;
padding: 1rem;
}
/* Styles pour les formulaires */
.form-text {
color: #858796;
}
/* Styles responsive */
@media (max-width: 768px) {
.ms-auto {
margin-left: 0 !important;
}
.col-lg-2 {
display: none;
}
.col-lg-10 {
flex: 0 0 100%;
max-width: 100%;
}
}

159
public/js/main.js Normal file
View File

@@ -0,0 +1,159 @@
/**
* Fichier JavaScript principal pour l'application Mail Notifier
*/
document.addEventListener('DOMContentLoaded', function() {
// Fermeture automatique des alertes après 5 secondes
setTimeout(function() {
const alerts = document.querySelectorAll('.alert');
alerts.forEach(function(alert) {
const bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
});
}, 5000);
// Activer les tooltips Bootstrap
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
// Gestionnaire d'événements pour la confirmation de suppression
const deleteForms = document.querySelectorAll('form[data-confirm]');
deleteForms.forEach(function(form) {
form.addEventListener('submit', function(event) {
const message = form.getAttribute('data-confirm') || 'Êtes-vous sûr de vouloir effectuer cette action?';
if (!confirm(message)) {
event.preventDefault();
}
});
});
// Affichage du temps écoulé depuis la réception des emails
const timestampElements = document.querySelectorAll('.timestamp');
timestampElements.forEach(function(element) {
const timestamp = new Date(element.getAttribute('data-timestamp'));
element.textContent = timeAgo(timestamp);
});
// Mise à jour automatique du tableau de bord
if (document.querySelector('#dashboard-content')) {
setInterval(refreshDashboard, 60000); // Actualiser toutes les minutes
}
// Fonction pour tester la configuration Pushover
const testPushoverBtn = document.getElementById('testPushover');
if (testPushoverBtn) {
testPushoverBtn.addEventListener('click', function() {
const user = document.getElementById('pushoverUser').value;
const token = document.getElementById('pushoverToken').value;
if (!user || !token) {
alert('Veuillez d\'abord configurer votre clé utilisateur et votre token d\'application Pushover.');
return;
}
testPushoverBtn.disabled = true;
testPushoverBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Envoi en cours...';
// Le formulaire est soumis par un événement onclick dans le HTML
});
}
});
/**
* Convertit une date en texte "il y a X temps"
* @param {Date} date - La date à convertir
* @returns {string} - Le texte formaté
*/
function timeAgo(date) {
const seconds = Math.floor((new Date() - date) / 1000);
let interval = Math.floor(seconds / 31536000);
if (interval > 1) {
return 'il y a ' + interval + ' ans';
}
if (interval === 1) {
return 'il y a 1 an';
}
interval = Math.floor(seconds / 2592000);
if (interval > 1) {
return 'il y a ' + interval + ' mois';
}
if (interval === 1) {
return 'il y a 1 mois';
}
interval = Math.floor(seconds / 86400);
if (interval > 1) {
return 'il y a ' + interval + ' jours';
}
if (interval === 1) {
return 'hier';
}
interval = Math.floor(seconds / 3600);
if (interval > 1) {
return 'il y a ' + interval + ' heures';
}
if (interval === 1) {
return 'il y a 1 heure';
}
interval = Math.floor(seconds / 60);
if (interval > 1) {
return 'il y a ' + interval + ' minutes';
}
if (interval === 1) {
return 'il y a 1 minute';
}
return 'à l\'instant';
}
/**
* Actualise le contenu du tableau de bord
*/
function refreshDashboard() {
fetch('/api/emails?limit=5')
.then(response => response.json())
.then(data => {
// Mettre à jour les compteurs
const totalCount = data.length;
const unreadCount = data.filter(email => !email.read).length;
document.getElementById('total-count').textContent = totalCount;
document.getElementById('unread-count').textContent = unreadCount;
// Mettre à jour la liste des derniers emails
const emailsList = document.getElementById('latest-emails');
if (emailsList && data.length > 0) {
let html = '';
data.forEach(email => {
const date = new Date(email.receivedAt).toLocaleString();
const status = email.read
? '<span class="badge bg-success">Lu</span>'
: '<span class="badge bg-warning">Non lu</span>';
html += `
<tr>
<td>${date}</td>
<td>${email.from}</td>
<td>${email.subject}</td>
<td>${status}</td>
<td>
<a href="/emails/${email.id}" class="btn btn-sm btn-info">
<i class="fas fa-eye"></i>
</a>
</td>
</tr>
`;
});
emailsList.innerHTML = html;
}
})
.catch(error => console.error('Erreur lors de l\'actualisation du tableau de bord:', error));
}

77
src/db/database.js Normal file
View File

@@ -0,0 +1,77 @@
const low = require('lowdb');
const FileSync = require('lowdb/adapters/FileSync');
const config = require('config');
const fs = require('fs');
const path = require('path');
// Chemin vers le fichier de base de données
const dbPath = config.get('db.path');
// S'assurer que le répertoire de la base de données existe
function ensureDbDirectory() {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
console.log(`Répertoire créé: ${dir}`);
}
}
// Initialiser la base de données
function init() {
try {
ensureDbDirectory();
const adapter = new FileSync(dbPath);
const db = low(adapter);
// Structure par défaut de la base de données
db.defaults({
emails: [],
settings: {
smtp: {
port: config.get('smtp.port'),
host: config.get('smtp.host'),
secure: config.get('smtp.secure'),
authOptional: config.get('smtp.authOptional')
},
pushover: {
user: config.get('pushover.user'),
token: config.get('pushover.token'),
title: config.get('pushover.title'),
sound: config.get('pushover.sound'),
priority: config.get('pushover.priority')
},
admin: {
username: config.get('admin.username'),
password: config.get('admin.password'),
enableAuth: config.get('admin.enableAuth')
},
version: '1.0.0',
createdAt: new Date(),
updatedAt: new Date()
}
}).write();
console.log('Base de données initialisée avec succès');
// Exposer l'instance de la base de données
module.exports.instance = db;
return db;
} catch (error) {
console.error('Erreur lors de l\'initialisation de la base de données:', error);
throw error;
}
}
// Si le module est déjà initialisé, retourner l'instance existante
if (!module.exports.instance) {
module.exports = {
init,
instance: null,
get: (collection) => module.exports.instance.get(collection),
set: (collection, value) => module.exports.instance.set(collection, value)
};
}
module.exports = module.exports;

117
src/mail-server.js Normal file
View File

@@ -0,0 +1,117 @@
const SMTPServer = require('smtp-server').SMTPServer;
const simpleParser = require('mailparser').simpleParser;
const config = require('config');
const pushover = require('./notifiers/pushover');
const Email = require('./models/email');
// Options du serveur SMTP
const smtpOptions = {
name: 'Local Mail Notifier',
banner: 'Local Mail Notifier - SMTP Server',
size: 1024 * 1024, // Taille maximale des messages: 1MB
authOptional: config.get('smtp.authOptional'),
disableAuth: !config.get('smtp.authRequired'),
onAuth(auth, session, callback) {
// Pour la simplicité, nous acceptons toute authentification
// En production, vous voudriez vérifier les identifiants
return callback(null, { user: auth.username });
},
onData(stream, session, callback) {
let mailData = '';
stream.on('data', (chunk) => {
mailData += chunk;
});
stream.on('end', async () => {
try {
// Parser l'email
const parsed = await simpleParser(mailData);
// Créer un objet email pour la BD
const email = {
from: parsed.from ? parsed.from.text : '',
to: parsed.to ? parsed.to.text : '',
subject: parsed.subject || '(Sans objet)',
text: parsed.text || '',
html: parsed.html || '',
receivedAt: new Date(),
attachments: parsed.attachments ? parsed.attachments.length : 0
};
// Sauvegarder l'email dans la BD
const savedEmail = await Email.create(email);
// Envoyer une notification
await sendNotification(savedEmail);
console.log(`Email reçu de ${email.from} - "${email.subject}"`);
callback();
} catch (error) {
console.error('Erreur lors du traitement de l\'email:', error);
callback(new Error('Erreur de traitement de l\'email'));
}
});
stream.on('error', (error) => {
console.error('Erreur de flux:', error);
callback(new Error('Erreur de lecture du flux'));
});
}
};
// Instance du serveur SMTP
let smtpServer = null;
// Fonction pour envoyer une notification
async function sendNotification(email) {
try {
// Construction du message
const message = {
title: `Nouvel email: ${email.subject}`,
message: `De: ${email.from}\n${email.text.substring(0, 500)}${email.text.length > 500 ? '...' : ''}`,
priority: config.get('pushover.priority'),
sound: config.get('pushover.sound'),
url: `http://localhost:${config.get('server.port')}/emails/${email.id}`,
url_title: 'Voir l\'email complet'
};
// Envoi via Pushover
await pushover.send(message);
console.log('Notification envoyée avec succès');
} catch (error) {
console.error('Erreur lors de l\'envoi de la notification:', error);
}
}
// Fonctions pour démarrer et arrêter le serveur
function start() {
const port = config.get('smtp.port');
const host = config.get('smtp.host');
smtpServer = new SMTPServer(smtpOptions);
smtpServer.on('error', err => {
console.error('Erreur du serveur SMTP:', err);
});
smtpServer.listen(port, host, () => {
console.log(`Serveur SMTP démarré sur ${host}:${port}`);
});
return smtpServer;
}
function stop() {
if (smtpServer) {
smtpServer.close();
console.log('Serveur SMTP arrêté');
}
}
module.exports = {
start,
stop
};

116
src/models/email.js Normal file
View File

@@ -0,0 +1,116 @@
// src/models/email.js
const db = require('../db/database');
const { nanoid } = require('nanoid');
/**
* Modèle pour gérer les emails
*/
class Email {
/**
* Crée un nouvel email dans la base de données
* @param {Object} emailData - Données de l'email
* @returns {Object} - L'email créé avec son ID
*/
static async create(emailData) {
try {
const email = {
id: nanoid(),
...emailData,
read: false
};
await db.get('emails')
.push(email)
.write();
return email;
} catch (error) {
console.error('Erreur lors de la création de l\'email:', error);
throw error;
}
}
/**
* Récupère tous les emails
* @param {Object} options - Options de filtrage
* @returns {Array} - Liste des emails
*/
static async getAll(options = {}) {
try {
let query = db.get('emails');
// Filtres
if (options.read !== undefined) {
query = query.filter({ read: options.read });
}
// Tri
query = query.orderBy(['receivedAt'], ['desc']);
// Pagination
if (options.limit) {
query = query.take(options.limit);
}
return query.value();
} catch (error) {
console.error('Erreur lors de la récupération des emails:', error);
throw error;
}
}
/**
* Récupère un email par son ID
* @param {string} id - ID de l'email
* @returns {Object} - L'email trouvé
*/
static async getById(id) {
try {
return db.get('emails')
.find({ id })
.value();
} catch (error) {
console.error('Erreur lors de la récupération de l\'email:', error);
throw error;
}
}
/**
* Marque un email comme lu
* @param {string} id - ID de l'email
* @returns {Object} - L'email mis à jour
*/
static async markAsRead(id) {
try {
await db.get('emails')
.find({ id })
.assign({ read: true })
.write();
return this.getById(id);
} catch (error) {
console.error('Erreur lors du marquage de l\'email comme lu:', error);
throw error;
}
}
/**
* Supprime un email
* @param {string} id - ID de l'email
* @returns {boolean} - true si l'email a été supprimé
*/
static async delete(id) {
try {
await db.get('emails')
.remove({ id })
.write();
return true;
} catch (error) {
console.error('Erreur lors de la suppression de l\'email:', error);
throw error;
}
}
}
module.exports = Email;

85
src/models/settings.js Normal file
View File

@@ -0,0 +1,85 @@
const db = require('../db/database');
const bcrypt = require('bcrypt');
/**
* Modèle pour gérer les paramètres
*/
class Settings {
/**
* Récupère tous les paramètres
* @returns {Object} - Les paramètres
*/
static async get() {
try {
return db.get('settings').value() || {};
} catch (error) {
console.error('Erreur lors de la récupération des paramètres:', error);
throw error;
}
}
/**
* Met à jour les paramètres
* @param {Object} settings - Les nouveaux paramètres
* @returns {Object} - Les paramètres mis à jour
*/
static async update(settings) {
try {
// Si le mot de passe est fourni, le hasher
if (settings.admin && settings.admin.password) {
settings.admin.password = await bcrypt.hash(settings.admin.password, 10);
}
// Fusion avec les paramètres existants
const currentSettings = await this.get();
const updatedSettings = {
...currentSettings,
...settings,
updatedAt: new Date()
};
await db.set('settings', updatedSettings).write();
return updatedSettings;
} catch (error) {
console.error('Erreur lors de la mise à jour des paramètres:', error);
throw error;
}
}
/**
* Vérifie les identifiants de l'administrateur
* @param {string} username - Nom d'utilisateur
* @param {string} password - Mot de passe
* @returns {boolean} - true si les identifiants sont valides
*/
static async verifyAdminCredentials(username, password) {
try {
const settings = await this.get();
// Si l'authentification est désactivée
if (!settings.admin || !settings.admin.enableAuth) {
return true;
}
// Vérifier le nom d'utilisateur
if (!settings.admin.username || settings.admin.username !== username) {
return false;
}
// Vérifier si le mot de passe est hashé
if (settings.admin.password && settings.admin.password.startsWith('$2')) {
// Mot de passe hashé
return await bcrypt.compare(password, settings.admin.password);
} else {
// Mot de passe en clair (pour la compatibilité avec les anciennes versions)
return settings.admin.password === password;
}
} catch (error) {
console.error('Erreur lors de la vérification des identifiants:', error);
return false;
}
}
}
module.exports = Settings;

70
src/notifiers/pushover.js Normal file
View File

@@ -0,0 +1,70 @@
const Push = require('pushover-notifications');
const config = require('config');
const Settings = require('../models/settings');
/**
* Envoie une notification via l'API Pushover
* @param {Object} message - Le message à envoyer
* @returns {Promise<Object>} - La réponse de l'API Pushover
*/
async function send(message) {
try {
// Obtenir les paramètres Pushover
const settings = await Settings.get();
const userKey = settings.pushover?.user || config.get('pushover.user');
const appToken = settings.pushover?.token || config.get('pushover.token');
// Vérifier si les identifiants sont configurés
if (!userKey || !appToken) {
throw new Error('Pushover n\'est pas configuré. Veuillez configurer la clé utilisateur et le token d\'application.');
}
// Initialiser le client Pushover
const push = new Push({
user: userKey,
token: appToken
});
// Préparer le message
const notification = {
title: message.title || settings.pushover?.title || config.get('pushover.title'),
message: message.message || 'Notification du serveur mail local',
priority: message.priority !== undefined ? message.priority : (settings.pushover?.priority || config.get('pushover.priority')),
sound: message.sound || settings.pushover?.sound || config.get('pushover.sound'),
url: message.url,
url_title: message.url_title
};
// Envoyer la notification
return new Promise((resolve, reject) => {
push.send(notification, (err, result) => {
if (err) {
console.error('Erreur Pushover:', err);
return reject(err);
}
console.log('Notification Pushover envoyée:', result);
resolve(result);
});
});
} catch (error) {
console.error('Erreur lors de l\'envoi de la notification Pushover:', error);
throw error;
}
}
/**
* Envoie une notification de test pour vérifier la configuration
* @returns {Promise<Object>} - La réponse de l'API Pushover
*/
async function sendTest() {
return send({
title: 'Test de notification',
message: 'Ceci est un test de notification du serveur mail local. Si vous recevez cette notification, la configuration est correcte.'
});
}
module.exports = {
send,
sendTest
};

139
src/routes/api.js Normal file
View File

@@ -0,0 +1,139 @@
const express = require('express');
const router = express.Router();
const Email = require('../models/email');
const Settings = require('../models/settings');
const pushover = require('../notifiers/pushover');
// Middleware d'authentification pour l'API
const authMiddleware = async (req, res, next) => {
// Pour simplifier, on utilise une authentification basique
// En production, utilisez un système d'authentification plus robuste
const settings = await Settings.get();
if (!settings.admin || !settings.admin.enableAuth) {
return next();
}
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Basic ')) {
return res.status(401).json({ error: 'Authentification requise' });
}
const base64Credentials = authHeader.split(' ')[1];
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf8');
const [username, password] = credentials.split(':');
const isValid = await Settings.verifyAdminCredentials(username, password);
if (!isValid) {
return res.status(401).json({ error: 'Identifiants invalides' });
}
next();
};
// Appliquer le middleware d'authentification à toutes les routes API
router.use(authMiddleware);
// Liste des emails
router.get('/emails', async (req, res) => {
try {
const options = {
read: req.query.read === 'true' ? true : (req.query.read === 'false' ? false : undefined),
limit: req.query.limit ? parseInt(req.query.limit) : undefined
};
const emails = await Email.getAll(options);
res.json(emails);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Détails d'un email
router.get('/emails/:id', async (req, res) => {
try {
const email = await Email.getById(req.params.id);
if (!email) {
return res.status(404).json({ error: 'Email non trouvé' });
}
res.json(email);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Marquer un email comme lu
router.put('/emails/:id/read', async (req, res) => {
try {
const email = await Email.markAsRead(req.params.id);
if (!email) {
return res.status(404).json({ error: 'Email non trouvé' });
}
res.json(email);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Supprimer un email
router.delete('/emails/:id', async (req, res) => {
try {
const success = await Email.delete(req.params.id);
if (!success) {
return res.status(404).json({ error: 'Email non trouvé' });
}
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Récupérer les paramètres
router.get('/settings', async (req, res) => {
try {
const settings = await Settings.get();
// Ne pas exposer le mot de passe
if (settings.admin && settings.admin.password) {
settings.admin.password = undefined;
}
res.json(settings);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Mettre à jour les paramètres
router.put('/settings', async (req, res) => {
try {
const updatedSettings = await Settings.update(req.body);
// Ne pas exposer le mot de passe
if (updatedSettings.admin && updatedSettings.admin.password) {
updatedSettings.admin.password = undefined;
}
res.json(updatedSettings);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Test de notification Pushover
router.post('/test-pushover', async (req, res) => {
try {
const result = await pushover.sendTest();
res.json({ success: true, result });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;

240
src/routes/web.js Normal file
View File

@@ -0,0 +1,240 @@
const express = require('express');
const router = express.Router();
const Email = require('../models/email');
const Settings = require('../models/settings');
const pushover = require('../notifiers/pushover');
// Middleware d'authentification
const authMiddleware = async (req, res, next) => {
try {
const settings = await Settings.get();
// Si l'authentification est désactivée
if (!settings.admin || !settings.admin.enableAuth) {
return next();
}
// Si l'utilisateur est déjà authentifié
if (req.session.isAuthenticated) {
return next();
}
// Rediriger vers la page de connexion
res.redirect('/login');
} catch (error) {
console.error('Erreur d\'authentification:', error);
res.status(500).render('error', {
message: 'Erreur d\'authentification',
error: error
});
}
};
// Page de connexion
router.get('/login', async (req, res) => {
try {
const settings = await Settings.get();
// Si l'authentification est désactivée, rediriger vers la page d'accueil
if (!settings.admin || !settings.admin.enableAuth) {
return res.redirect('/');
}
res.render('login');
} catch (error) {
res.status(500).render('error', {
message: 'Erreur lors du chargement de la page de connexion',
error: error
});
}
});
// Traitement de la connexion
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
const isValid = await Settings.verifyAdminCredentials(username, password);
if (!isValid) {
req.flash('error_msg', 'Identifiants invalides');
return res.redirect('/login');
}
// Authentification réussie
req.session.isAuthenticated = true;
req.flash('success_msg', 'Connexion réussie');
res.redirect('/');
} catch (error) {
res.status(500).render('error', {
message: 'Erreur lors de la connexion',
error: error
});
}
});
// Déconnexion
router.get('/logout', (req, res) => {
req.session.destroy(() => {
res.redirect('/login');
});
});
// Appliquer le middleware d'authentification aux routes protégées
router.use(authMiddleware);
// Page d'accueil - Tableau de bord
router.get('/', async (req, res) => {
try {
// Récupérer des statistiques
const emails = await Email.getAll();
const unreadCount = emails.filter(email => !email.read).length;
const totalCount = emails.length;
const latestEmails = await Email.getAll({ limit: 5 });
res.render('dashboard', {
unreadCount,
totalCount,
latestEmails
});
} catch (error) {
res.status(500).render('error', {
message: 'Erreur lors du chargement du tableau de bord',
error: error
});
}
});
// Liste des emails
router.get('/emails', async (req, res) => {
try {
const options = {
read: req.query.read === 'true' ? true : (req.query.read === 'false' ? false : undefined)
};
const emails = await Email.getAll(options);
res.render('emails', {
emails,
filter: req.query.read
});
} catch (error) {
res.status(500).render('error', {
message: 'Erreur lors du chargement des emails',
error: error
});
}
});
// Détails d'un email
router.get('/emails/:id', async (req, res) => {
try {
const email = await Email.getById(req.params.id);
if (!email) {
req.flash('error_msg', 'Email non trouvé');
return res.redirect('/emails');
}
// Marquer l'email comme lu s'il ne l'est pas déjà
if (!email.read) {
await Email.markAsRead(email.id);
}
res.render('email-detail', {
email
});
} catch (error) {
res.status(500).render('error', {
message: 'Erreur lors du chargement de l\'email',
error: error
});
}
});
// Supprimer un email (via formulaire)
router.delete('/emails/:id', async (req, res) => {
try {
await Email.delete(req.params.id);
req.flash('success_msg', 'Email supprimé avec succès');
res.redirect('/emails');
} catch (error) {
req.flash('error_msg', 'Erreur lors de la suppression de l\'email');
res.redirect(`/emails/${req.params.id}`);
}
});
// Page des paramètres
router.get('/settings', async (req, res) => {
try {
const settings = await Settings.get();
// Ne pas afficher le mot de passe
if (settings.admin && settings.admin.password) {
settings.admin.password = '';
}
res.render('settings', {
settings
});
} catch (error) {
res.status(500).render('error', {
message: 'Erreur lors du chargement des paramètres',
error: error
});
}
});
// Mettre à jour les paramètres
router.post('/settings', async (req, res) => {
try {
// Formater les données
const settings = {
smtp: {
port: parseInt(req.body.smtpPort),
host: req.body.smtpHost,
secure: req.body.smtpSecure === 'true',
authOptional: req.body.smtpAuthOptional === 'true'
},
pushover: {
user: req.body.pushoverUser,
token: req.body.pushoverToken,
title: req.body.pushoverTitle,
sound: req.body.pushoverSound,
priority: parseInt(req.body.pushoverPriority)
},
admin: {
username: req.body.adminUsername,
enableAuth: req.body.enableAuth === 'true'
}
};
// Ajouter le mot de passe seulement s'il est fourni
if (req.body.adminPassword) {
settings.admin.password = req.body.adminPassword;
}
await Settings.update(settings);
req.flash('success_msg', 'Paramètres mis à jour avec succès');
res.redirect('/settings');
} catch (error) {
req.flash('error_msg', 'Erreur lors de la mise à jour des paramètres');
res.redirect('/settings');
}
});
// Test de notification Pushover
router.post('/test-pushover', async (req, res) => {
try {
await pushover.sendTest();
req.flash('success_msg', 'Notification de test envoyée avec succès');
} catch (error) {
req.flash('error_msg', `Erreur lors de l'envoi de la notification: ${error.message}`);
}
res.redirect('/settings');
});
module.exports = router;

80
src/server.js Normal file
View File

@@ -0,0 +1,80 @@
const express = require('express');
const path = require('path');
const config = require('config');
const bodyParser = require('body-parser');
const session = require('express-session');
const flash = require('connect-flash');
const methodOverride = require('method-override');
const expressLayouts = require('express-ejs-layouts');
// Importation des modules personnalisés
const mailServer = require('./mail-server');
const apiRoutes = require('./routes/api');
const webRoutes = require('./routes/web');
const database = require('./db/database');
// Initialisation de la base de données
database.init();
// Création de l'application Express
const app = express();
const port = config.get('server.port') || 3000;
// Configuration des vues
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, '../views'));
app.use(expressLayouts);
app.set('layout', 'layouts/main');
app.set('layout extractScripts', true);
app.set('layout extractStyles', true);
// Middleware
app.use(express.static(path.join(__dirname, '../public')));
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
app.use(methodOverride('_method'));
app.use(session({
secret: config.get('server.sessionSecret'),
resave: false,
saveUninitialized: false
}));
app.use(flash());
// Variables globales pour les templates
app.use((req, res, next) => {
res.locals.success_msg = req.flash('success_msg');
res.locals.error_msg = req.flash('error_msg');
res.locals.error = req.flash('error');
next();
});
// Routes
app.use('/api', apiRoutes);
app.use('/', webRoutes);
// Démarrage du serveur HTTP
app.listen(port, () => {
console.log(`Serveur web démarré sur le port ${port}`);
});
// Démarrage du serveur SMTP
mailServer.start();
// Gestion des erreurs non capturées
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err);
// En production, on pourrait vouloir redémarrer proprement le serveur ici
// mais pour le développement, on laisse le processus se terminer
if (process.env.NODE_ENV === 'production') {
// Fermeture propre des serveurs
mailServer.stop();
process.exit(1);
}
});
module.exports = app;

148
views/dashboard.ejs Normal file
View File

@@ -0,0 +1,148 @@
<div class="container-fluid">
<h1 class="h3 mb-4">Tableau de bord</h1>
<div class="row">
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-primary shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
Emails totaux</div>
<div class="h5 mb-0 font-weight-bold text-gray-800" id="total-count"><%= totalCount %></div>
</div>
<div class="col-auto">
<i class="fas fa-envelope fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-warning shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
Emails non lus</div>
<div class="h5 mb-0 font-weight-bold text-gray-800" id="unread-count"><%= unreadCount %></div>
</div>
<div class="col-auto">
<i class="fas fa-envelope-open fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
<h6 class="m-0 font-weight-bold text-primary">Derniers emails reçus</h6>
<a href="/emails" class="btn btn-sm btn-primary">Voir tous les emails</a>
</div>
<div class="card-body">
<% if(latestEmails && latestEmails.length > 0) { %>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">De</th>
<th scope="col">Sujet</th>
<th scope="col">Statut</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="latest-emails">
<% latestEmails.forEach(email => { %>
<tr>
<td><%= new Date(email.receivedAt).toLocaleString() %></td>
<td><%= email.from %></td>
<td><%= email.subject %></td>
<td>
<% if(email.read) { %>
<span class="badge bg-success">Lu</span>
<% } else { %>
<span class="badge bg-warning">Non lu</span>
<% } %>
</td>
<td>
<a href="/emails/<%= email.id %>" class="btn btn-sm btn-info">
<i class="fas fa-eye"></i>
</a>
</td>
</tr>
<% }); %>
</tbody>
</table>
</div>
<% } else { %>
<div class="text-center py-4">
<i class="fas fa-inbox fa-3x text-muted mb-3"></i>
<p>Aucun email reçu pour le moment.</p>
</div>
<% } %>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Statut du serveur</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<p><strong>Serveur SMTP :</strong> <span class="text-success">Actif sur <%= settings.smtp?.host || '0.0.0.0' %>:<%= settings.smtp?.port || '2525' %></span></p>
<p><strong>Mode TLS :</strong>
<% if(settings.smtp?.secure) { %>
<span class="text-success">Activé</span>
<% } else { %>
<span class="text-warning">Désactivé</span>
<% } %>
</p>
</div>
<div class="col-md-6">
<p><strong>Notifications Pushover :</strong>
<% if(settings.pushover?.user && settings.pushover?.token) { %>
<span class="text-success">Configurées</span>
<% } else { %>
<span class="text-danger">Non configurées</span>
<% } %>
</p>
<p><strong>Priorité des notifications :</strong>
<%
let priorityText = "Normale";
let priorityClass = "text-info";
if(settings.pushover?.priority === -2) {
priorityText = "Très basse";
priorityClass = "text-muted";
} else if(settings.pushover?.priority === -1) {
priorityText = "Basse";
priorityClass = "text-secondary";
} else if(settings.pushover?.priority === 1) {
priorityText = "Haute";
priorityClass = "text-warning";
} else if(settings.pushover?.priority === 2) {
priorityText = "Urgente";
priorityClass = "text-danger";
}
%>
<span class="<%= priorityClass %>"><%= priorityText %></span>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

83
views/email-detail.ejs Normal file
View File

@@ -0,0 +1,83 @@
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<a href="/emails" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Retour
</a>
</div>
<div>
<form action="/emails/<%= email.id %>?_method=DELETE" method="POST" class="d-inline" onsubmit="return confirm('Êtes-vous sûr de vouloir supprimer cet email?');">
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash"></i> Supprimer
</button>
</form>
</div>
</div>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary"><%= email.subject %></h6>
</div>
<div class="card-body">
<div class="mb-4">
<div class="row">
<div class="col-md-6">
<p><strong>De:</strong> <%= email.from %></p>
</div>
<div class="col-md-6 text-md-end">
<p><strong>Reçu le:</strong> <%= new Date(email.receivedAt).toLocaleString() %></p>
</div>
</div>
<p><strong>À:</strong> <%= email.to %></p>
<% if(email.attachments && email.attachments > 0) { %>
<p><strong>Pièces jointes:</strong> <%= email.attachments %></p>
<% } %>
</div>
<hr>
<% if(email.html) { %>
<div class="email-content-html mb-3">
<iframe srcdoc="<%= email.html %>" style="width: 100%; height: 500px; border: 1px solid #ddd; border-radius: 0.25rem;"></iframe>
</div>
<div class="accordion">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseText">
Voir en texte brut
</button>
</h2>
<div id="collapseText" class="accordion-collapse collapse">
<div class="accordion-body">
<pre class="text-content" style="white-space: pre-wrap;"><%= email.text %></pre>
</div>
</div>
</div>
</div>
<% } else { %>
<pre class="text-content" style="white-space: pre-wrap;"><%= email.text %></pre>
<% } %>
</div>
</div>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Informations techniques</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<p><strong>ID:</strong> <%= email.id %></p>
<p><strong>Taille:</strong> <%= email.text ? email.text.length : 0 %> caractères (texte brut)</p>
</div>
<div class="col-md-6">
<p><strong>Format HTML:</strong> <%= email.html ? 'Oui' : 'Non' %></p>
<p><strong>Statut:</strong> <%= email.read ? 'Lu' : 'Non lu' %></p>
</div>
</div>
</div>
</div>
</div>

78
views/emails.ejs Normal file
View File

@@ -0,0 +1,78 @@
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">Emails reçus</h1>
<div class="btn-group">
<a href="/emails" class="btn btn-outline-primary <%= !filter ? 'active' : '' %>">Tous</a>
<a href="/emails?read=false" class="btn btn-outline-primary <%= filter === 'false' ? 'active' : '' %>">Non lus</a>
<a href="/emails?read=true" class="btn btn-outline-primary <%= filter === 'true' ? 'active' : '' %>">Lus</a>
</div>
</div>
<div class="card shadow">
<div class="card-body">
<% if(emails && emails.length > 0) { %>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">De</th>
<th scope="col">Sujet</th>
<th scope="col">Statut</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<% emails.forEach(email => { %>
<tr class="<%= !email.read ? 'table-warning' : '' %>">
<td>
<span class="timestamp" data-timestamp="<%= email.receivedAt %>">
<%= new Date(email.receivedAt).toLocaleString() %>
</span>
</td>
<td><%= email.from %></td>
<td><%= email.subject %></td>
<td>
<% if(email.read) { %>
<span class="badge bg-success">Lu</span>
<% } else { %>
<span class="badge bg-warning">Non lu</span>
<% } %>
</td>
<td>
<div class="btn-group">
<a href="/emails/<%= email.id %>" class="btn btn-sm btn-info">
<i class="fas fa-eye"></i>
</a>
<form action="/emails/<%= email.id %>?_method=DELETE" method="POST" onsubmit="return confirm('Êtes-vous sûr de vouloir supprimer cet email?');" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
</td>
</tr>
<% }); %>
</tbody>
</table>
</div>
<% } else { %>
<div class="text-center py-5">
<i class="fas fa-inbox fa-4x text-muted mb-3"></i>
<h5>Aucun email trouvé</h5>
<p class="text-muted">
<% if (filter === 'true') { %>
Il n'y a pas d'emails lus.
<% } else if (filter === 'false') { %>
Il n'y a pas d'emails non lus.
<% } else { %>
Les emails reçus apparaîtront ici.
<% } %>
</p>
</div>
<% } %>
</div>
</div>
</div>

22
views/error.ejs Normal file
View File

@@ -0,0 +1,22 @@
<%- include('layouts/main', { title: 'Erreur' }) %>
<div class="container-fluid">
<div class="text-center mt-5">
<div class="error mx-auto" data-text="Error">
<i class="fas fa-exclamation-triangle fa-3x text-danger mb-3"></i>
</div>
<h3 class="text-danger">Une erreur est survenue</h3>
<p class="lead text-gray-800 mb-4"><%= message %></p>
<% if (process.env.NODE_ENV !== 'production' && error) { %>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Détails de l'erreur</h6>
</div>
<div class="card-body">
<pre class="text-danger"><%= error.stack || error.message || error %></pre>
</div>
</div>
<% } %>
<a href="/">&larr; Retour à l'accueil</a>
</div>
</div>

16
views/index.ejs Normal file
View File

@@ -0,0 +1,16 @@
<!-- Page d'accueil - redirige vers le tableau de bord -->
<%- include('layouts/main', { title: 'Accueil' }) %>
<script>
window.location.href = "/dashboard";
</script>
<div class="container-fluid">
<div class="text-center mt-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Redirection...</span>
</div>
<p class="mt-3">Redirection vers le tableau de bord...</p>
<p>Si vous n'êtes pas redirigé automatiquement, <a href="/dashboard">cliquez ici</a>.</p>
</div>
</div>

97
views/layouts/main.ejs Normal file
View File

@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mail Notifier - <%= typeof title !== 'undefined' ? title : 'Serveur de mail avec notifications' %></title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom CSS -->
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="container-fluid">
<div class="row">
<% if (typeof hideNavbar === 'undefined' || !hideNavbar) { %>
<!-- Sidebar -->
<div class="col-md-3 col-lg-2 px-0 bg-dark position-fixed" style="min-height:100vh">
<div class="d-flex flex-column p-3 text-white bg-dark" style="height:100%">
<a href="/" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-white text-decoration-none">
<i class="fas fa-envelope me-2"></i>
<span class="fs-4">Mail Notifier</span>
</a>
<hr>
<ul class="nav nav-pills flex-column mb-auto">
<li class="nav-item">
<a href="/" class="nav-link text-white <%= (typeof activePage !== 'undefined' && activePage === 'dashboard') ? 'active' : '' %>">
<i class="fas fa-tachometer-alt me-2"></i>
Tableau de bord
</a>
</li>
<li>
<a href="/emails" class="nav-link text-white <%= (typeof activePage !== 'undefined' && activePage === 'emails') ? 'active' : '' %>">
<i class="fas fa-envelope-open-text me-2"></i>
Emails reçus
</a>
</li>
<li>
<a href="/settings" class="nav-link text-white <%= (typeof activePage !== 'undefined' && activePage === 'settings') ? 'active' : '' %>">
<i class="fas fa-cog me-2"></i>
Paramètres
</a>
</li>
</ul>
<hr>
<div>
<a href="/logout" class="nav-link text-white">
<i class="fas fa-sign-out-alt me-2"></i>
Déconnexion
</a>
</div>
</div>
</div>
<!-- Main content -->
<div class="col-md-9 col-lg-10 ms-auto p-4">
<% } else { %>
<!-- Full width content (for login page) -->
<div class="col-12 p-0">
<% } %>
<!-- Flash messages -->
<% if(typeof success_msg !== 'undefined' && success_msg.length > 0) { %>
<div class="alert alert-success alert-dismissible fade show">
<%= success_msg %>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<% } %>
<% if(typeof error_msg !== 'undefined' && error_msg.length > 0) { %>
<div class="alert alert-danger alert-dismissible fade show">
<%= error_msg %>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<% } %>
<% if(typeof error !== 'undefined' && error.length > 0) { %>
<div class="alert alert-danger alert-dismissible fade show">
<%= error %>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<% } %>
<!-- Content -->
<%- typeof body !== 'undefined' ? body : '' %>
</div>
</div>
</div>
<!-- Bootstrap JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- Custom JS -->
<script src="/js/main.js"></script>
</body>
</html>

27
views/login.ejs Normal file
View File

@@ -0,0 +1,27 @@
<%- include('layouts/main', { hideNavbar: true, title: 'Connexion' }) %>
<div class="min-vh-100 d-flex align-items-center justify-content-center bg-light">
<div class="card shadow-sm" style="width: 400px;">
<div class="card-body p-4">
<div class="text-center mb-4">
<i class="fas fa-envelope fa-3x text-primary mb-3"></i>
<h3>Mail Notifier</h3>
<p class="text-muted">Connexion à l'interface d'administration</p>
</div>
<form action="/login" method="POST">
<div class="mb-3">
<label for="username" class="form-label">Nom d'utilisateur</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Mot de passe</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">Se connecter</button>
</div>
</form>
</div>
</div>
</div>

13
views/not-found.ejs Normal file
View File

@@ -0,0 +1,13 @@
<%- include('layouts/main', { title: 'Page non trouvée' }) %>
<div class="container-fluid">
<div class="text-center mt-5">
<div class="error mx-auto" data-text="404">
<i class="fas fa-search fa-3x text-warning mb-3"></i>
</div>
<h3 class="text-warning">Page non trouvée</h3>
<p class="lead text-gray-800 mb-5">La page que vous recherchez n'existe pas</p>
<p class="text-gray-500 mb-0">Il semble que vous ayez trouvé un bug dans la matrice...</p>
<a href="/">&larr; Retour à l'accueil</a>
</div>
</div>

View File

@@ -0,0 +1,6 @@
<!-- Footer partial - inclus dans certaines vues -->
<footer class="mt-auto py-3 bg-light">
<div class="container-fluid text-center">
<span class="text-muted">Mail Notifier &copy; <%= new Date().getFullYear() %> | Serveur mail local avec notifications Pushover</span>
</div>
</footer>

21
views/partials/header.ejs Normal file
View File

@@ -0,0 +1,21 @@
<!-- Header partial - inclus dans certaines vues -->
<header class="bg-white shadow-sm mb-4">
<div class="d-flex justify-content-between align-items-center p-3">
<h1 class="h4 mb-0"><%= typeof pageTitle !== 'undefined' ? pageTitle : 'Mail Notifier' %></h1>
<% if (typeof showActions !== 'undefined' && showActions) { %>
<div class="btn-group">
<% if (typeof actionButtons !== 'undefined') { %>
<% actionButtons.forEach(function(button) { %>
<a href="<%= button.href %>" class="btn <%= button.class || 'btn-outline-primary' %>">
<% if (button.icon) { %>
<i class="fas fa-<%= button.icon %> me-1"></i>
<% } %>
<%= button.text %>
</a>
<% }); %>
<% } %>
</div>
<% } %>
</div>
</header>

173
views/settings.ejs Normal file
View File

@@ -0,0 +1,173 @@
<div class="container-fluid">
<h1 class="h3 mb-4">Paramètres</h1>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Configuration</h6>
</div>
<div class="card-body">
<form action="/settings" method="POST">
<h5 class="mb-3">Serveur SMTP</h5>
<div class="row mb-4">
<div class="col-md-6">
<div class="mb-3">
<label for="smtpHost" class="form-label">Hôte</label>
<input type="text" class="form-control" id="smtpHost" name="smtpHost" value="<%= settings.smtp?.host || '0.0.0.0' %>">
<div class="form-text">Adresse IP à laquelle le serveur SMTP sera lié</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="smtpPort" class="form-label">Port</label>
<input type="number" class="form-control" id="smtpPort" name="smtpPort" value="<%= settings.smtp?.port || 2525 %>">
<div class="form-text">Port sur lequel le serveur SMTP écoutera</div>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-md-6">
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="smtpSecure" name="smtpSecure" value="true" <%= settings.smtp?.secure ? 'checked' : '' %>>
<label class="form-check-label" for="smtpSecure">
Connexion sécurisée (TLS)
</label>
<div class="form-text">Activer TLS pour les connexions SMTP</div>
</div>
</div>
<div class="col-md-6">
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="smtpAuthOptional" name="smtpAuthOptional" value="true" <%= settings.smtp?.authOptional ? 'checked' : '' %>>
<label class="form-check-label" for="smtpAuthOptional">
Authentification optionnelle
</label>
<div class="form-text">Permettre les connexions sans authentification</div>
</div>
</div>
</div>
<hr class="my-4">
<h5 class="mb-3">Notifications Pushover</h5>
<div class="row mb-4">
<div class="col-md-6">
<div class="mb-3">
<label for="pushoverUser" class="form-label">Clé utilisateur</label>
<input type="text" class="form-control" id="pushoverUser" name="pushoverUser" value="<%= settings.pushover?.user || '' %>">
<div class="form-text">Votre clé utilisateur Pushover</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="pushoverToken" class="form-label">Token d'application</label>
<input type="text" class="form-control" id="pushoverToken" name="pushoverToken" value="<%= settings.pushover?.token || '' %>">
<div class="form-text">Token API de votre application Pushover</div>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-md-4">
<div class="mb-3">
<label for="pushoverTitle" class="form-label">Titre des notifications</label>
<input type="text" class="form-control" id="pushoverTitle" name="pushoverTitle" value="<%= settings.pushover?.title || 'Mail Notifier' %>">
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="pushoverSound" class="form-label">Son</label>
<select class="form-select" id="pushoverSound" name="pushoverSound">
<option value="pushover" <%= settings.pushover?.sound === 'pushover' ? 'selected' : '' %>>Pushover (défaut)</option>
<option value="bike" <%= settings.pushover?.sound === 'bike' ? 'selected' : '' %>>Bike</option>
<option value="bugle" <%= settings.pushover?.sound === 'bugle' ? 'selected' : '' %>>Bugle</option>
<option value="cashregister" <%= settings.pushover?.sound === 'cashregister' ? 'selected' : '' %>>Cash Register</option>
<option value="classical" <%= settings.pushover?.sound === 'classical' ? 'selected' : '' %>>Classical</option>
<option value="cosmic" <%= settings.pushover?.sound === 'cosmic' ? 'selected' : '' %>>Cosmic</option>
<option value="falling" <%= settings.pushover?.sound === 'falling' ? 'selected' : '' %>>Falling</option>
<option value="gamelan" <%= settings.pushover?.sound === 'gamelan' ? 'selected' : '' %>>Gamelan</option>
<option value="incoming" <%= settings.pushover?.sound === 'incoming' ? 'selected' : '' %>>Incoming</option>
<option value="magic" <%= settings.pushover?.sound === 'magic' ? 'selected' : '' %>>Magic</option>
<option value="none" <%= settings.pushover?.sound === 'none' ? 'selected' : '' %>>Aucun son</option>
</select>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="pushoverPriority" class="form-label">Priorité</label>
<select class="form-select" id="pushoverPriority" name="pushoverPriority">
<option value="-2" <%= settings.pushover?.priority === -2 ? 'selected' : '' %>>Très basse (-2)</option>
<option value="-1" <%= settings.pushover?.priority === -1 ? 'selected' : '' %>>Basse (-1)</option>
<option value="0" <%= settings.pushover?.priority === 0 || !settings.pushover?.priority ? 'selected' : '' %>>Normale (0)</option>
<option value="1" <%= settings.pushover?.priority === 1 ? 'selected' : '' %>>Haute (1)</option>
<option value="2" <%= settings.pushover?.priority === 2 ? 'selected' : '' %>>Urgente (2)</option>
</select>
<div class="form-text">Les notifications urgentes (2) contournent le mode silencieux et nécessitent une confirmation</div>
</div>
</div>
</div>
<div class="mb-4">
<button type="button" class="btn btn-info" id="testPushover" onclick="document.getElementById('testPushoverForm').submit();">
<i class="fas fa-bell"></i> Tester les notifications
</button>
<div class="form-text">Envoie une notification de test pour vérifier votre configuration</div>
</div>
<hr class="my-4">
<h5 class="mb-3">Administration</h5>
<div class="row mb-4">
<div class="col-md-6">
<div class="mb-3">
<label for="adminUsername" class="form-label">Nom d'utilisateur</label>
<input type="text" class="form-control" id="adminUsername" name="adminUsername" value="<%= settings.admin?.username || 'admin' %>">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="adminPassword" class="form-label">Mot de passe</label>
<input type="password" class="form-control" id="adminPassword" name="adminPassword" placeholder="Laisser vide pour ne pas changer">
<div class="form-text">Laisser vide pour conserver le mot de passe actuel</div>
</div>
</div>
</div>
<div class="form-check mb-4">
<input class="form-check-input" type="checkbox" id="enableAuth" name="enableAuth" value="true" <%= settings.admin?.enableAuth ? 'checked' : '' %>>
<label class="form-check-label" for="enableAuth">
Activer l'authentification
</label>
<div class="form-text">Si désactivé, aucune connexion ne sera requise pour accéder à l'interface d'administration</div>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Enregistrer les paramètres
</button>
</div>
</form>
<!-- Formulaire de test Pushover (soumis via JS) -->
<form id="testPushoverForm" action="/test-pushover" method="POST" class="d-none"></form>
</div>
</div>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Informations système</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<p><strong>Version:</strong> <%= settings.version || '1.0.0' %></p>
<p><strong>Mode:</strong> <%= process.env.NODE_ENV || 'development' %></p>
</div>
<div class="col-md-6">
<p><strong>Démarré le:</strong> <%= settings.createdAt ? new Date(settings.createdAt).toLocaleString() : new Date().toLocaleString() %></p>
<p><strong>Modifié le:</strong> <%= settings.updatedAt ? new Date(settings.updatedAt).toLocaleString() : new Date().toLocaleString() %></p>
</div>
</div>
</div>
</div>
</div>