فهرست منبع

feat: Phase 2 — notifications email

Table settings (clé/valeur SQLite), module notifications.py (smtplib
STARTTLS/SSL), page /settings avec test SMTP, déclenchement automatique
après chaque job (succès + erreur). Phase 2 complète.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cédric Hansen 1 روز پیش
والد
کامیت
391cdb3475
7فایلهای تغییر یافته به همراه286 افزوده شده و 2 حذف شده
  1. 1 1
      doc/CDC_backupmanager_ynh.md
  2. 62 1
      sources/app.py
  3. 7 0
      sources/db.py
  4. 6 0
      sources/jobs/ynh_backup.py
  5. 102 0
      sources/notifications.py
  6. 1 0
      sources/templates/base.html
  7. 107 0
      sources/templates/settings.html

+ 1 - 1
doc/CDC_backupmanager_ynh.md

@@ -360,7 +360,7 @@ rsync -az -e "ssh -i $key -p $port" archive.tar archive.info.json user@host:/hom
 - [x] Restauration complète custom_dir
 - [x] Restauration complète DB (mysql + postgresql)
 - [x] Destinations rsync SSH / SFTP
-- [ ] Notifications email
+- [x] Notifications email
 
 ### Phase 3 — Fédération
 - [ ] API REST complète

+ 62 - 1
sources/app.py

@@ -43,7 +43,7 @@ logging.basicConfig(
 
 # --- Extensions --------------------------------------------------------------
 
-from db import db, Job, Run, Destination
+from db import db, Job, Run, Destination, Setting
 
 db.init_app(app)
 
@@ -390,6 +390,67 @@ def _get_pub_key(dest):
     from jobs.transfer import get_public_key
     return get_public_key(dest.key_name, app.config["DATA_DIR"])
 
+# --- Paramètres --------------------------------------------------------------
+
+_SETTING_KEYS = [
+    "smtp_host", "smtp_port", "smtp_user", "smtp_password",
+    "smtp_from", "smtp_to", "smtp_tls", "smtp_ssl",
+    "notify_on_success", "notify_on_error",
+]
+
+
+def _get_setting(key, default=""):
+    s = Setting.query.filter_by(key=key).first()
+    return s.value if s else default
+
+
+@app.route("/settings", methods=["GET", "POST"])
+def settings():
+    if request.method == "POST":
+        action = request.form.get("action")
+
+        if action == "test_smtp":
+            from notifications import send_test_email
+            try:
+                send_test_email(
+                    host=request.form.get("smtp_host", "").strip(),
+                    port=int(request.form.get("smtp_port", 587) or 587),
+                    user=request.form.get("smtp_user", "").strip(),
+                    password=request.form.get("smtp_password", ""),
+                    from_addr=request.form.get("smtp_from", "").strip(),
+                    to_addr=request.form.get("smtp_to", "").strip(),
+                    use_ssl=request.form.get("smtp_ssl") == "1",
+                    use_tls=request.form.get("smtp_tls") == "1",
+                )
+                flash("Email de test envoyé avec succès.", "success")
+            except Exception as exc:
+                flash(f"Échec du test SMTP : {exc}", "error")
+        else:
+            for key in _SETTING_KEYS:
+                if key in ("smtp_tls", "smtp_ssl", "notify_on_success", "notify_on_error"):
+                    value = "1" if request.form.get(key) == "1" else "0"
+                else:
+                    value = request.form.get(key, "").strip()
+                s = Setting.query.filter_by(key=key).first()
+                if s is None:
+                    s = Setting(key=key, value=value)
+                    db.session.add(s)
+                else:
+                    s.value = value
+            db.session.commit()
+            flash("Paramètres enregistrés.", "success")
+
+        return redirect(url_for("settings"))
+
+    cfg = {k: _get_setting(k) for k in _SETTING_KEYS}
+    # valeurs par défaut pour l'affichage
+    cfg.setdefault("smtp_port", "587")
+    cfg["smtp_tls"] = cfg.get("smtp_tls") or "1"
+    cfg["smtp_ssl"] = cfg.get("smtp_ssl") or "0"
+    cfg["notify_on_error"] = cfg.get("notify_on_error") or "1"
+    return render_template("settings.html", cfg=cfg)
+
+
 # --- API v1 ------------------------------------------------------------------
 
 @app.route("/api/v1/health")

+ 7 - 0
sources/db.py

@@ -47,6 +47,13 @@ class Job(db.Model):
         return get_next_run(self.id)
 
 
+class Setting(db.Model):
+    __tablename__ = "settings"
+
+    key = db.Column(db.Text, primary_key=True)
+    value = db.Column(db.Text, nullable=False, default="")
+
+
 class Run(db.Model):
     __tablename__ = "runs"
 

+ 6 - 0
sources/jobs/ynh_backup.py

@@ -72,6 +72,12 @@ def execute_job(job_id):
         run.finished_at = datetime.utcnow()
         db.session.commit()
 
+    try:
+        from notifications import send_job_notification
+        send_job_notification(run, job)
+    except Exception:
+        pass
+
 
 def _archive_name(instance, label):
     date_str = datetime.utcnow().strftime("%Y%m%d")

+ 102 - 0
sources/notifications.py

@@ -0,0 +1,102 @@
+import smtplib
+import ssl
+from email.mime.text import MIMEText
+
+from flask import current_app
+
+
+def _get(key, default=""):
+    from db import Setting
+    s = Setting.query.filter_by(key=key).first()
+    return s.value if s else default
+
+
+def send_job_notification(run, job):
+    """Envoie une notification email après un job. Silencieux si non configuré."""
+    if run.status == "success" and _get("notify_on_success", "0") != "1":
+        return
+    if run.status == "error" and _get("notify_on_error", "1") != "1":
+        return
+
+    smtp_host = _get("smtp_host")
+    smtp_to = _get("smtp_to")
+    if not smtp_host or not smtp_to:
+        return
+
+    instance = current_app.config.get("INSTANCE_NAME", "backupmanager")
+
+    if run.status == "success":
+        subject = f"[{instance}] ✓ {run.archive_name or job.name} — sauvegarde réussie"
+        d = run.duration_seconds or 0
+        duration = f"{d // 60}min {d % 60}s" if d >= 60 else f"{d}s"
+        body = (
+            f"Sauvegarde réussie\n\n"
+            f"Job      : {job.name}\n"
+            f"Type     : {job.type}\n"
+            f"Archive  : {run.archive_name}\n"
+            f"Taille   : {run.size_human}\n"
+            f"Durée    : {duration}\n"
+            f"Instance : {instance}\n"
+        )
+    else:
+        subject = f"[{instance}] ✗ {job.name} — ERREUR de sauvegarde"
+        body = (
+            f"Erreur lors de la sauvegarde\n\n"
+            f"Job      : {job.name}\n"
+            f"Type     : {job.type}\n"
+            f"Instance : {instance}\n\n"
+            f"Détail :\n{run.log_text or '(aucun log)'}\n"
+        )
+
+    try:
+        _send(
+            host=smtp_host,
+            port=int(_get("smtp_port", "587")),
+            user=_get("smtp_user"),
+            password=_get("smtp_password"),
+            from_addr=_get("smtp_from") or _get("smtp_user"),
+            to_addr=smtp_to,
+            subject=subject,
+            body=body,
+            use_ssl=_get("smtp_ssl", "0") == "1",
+            use_tls=_get("smtp_tls", "1") == "1",
+        )
+    except Exception as exc:
+        current_app.logger.warning(f"Notification email échouée : {exc}")
+
+
+def send_test_email(host, port, user, password, from_addr, to_addr, use_ssl, use_tls):
+    """Envoie un email de test. Lève une exception si ça échoue."""
+    _send(
+        host=host,
+        port=int(port),
+        user=user,
+        password=password,
+        from_addr=from_addr or user,
+        to_addr=to_addr,
+        subject="Test SMTP — Backup Manager",
+        body="Si vous recevez cet email, la configuration SMTP est correcte.",
+        use_ssl=use_ssl,
+        use_tls=use_tls,
+    )
+
+
+def _send(host, port, user, password, from_addr, to_addr, subject, body, use_ssl, use_tls):
+    msg = MIMEText(body, "plain", "utf-8")
+    msg["Subject"] = subject
+    msg["From"] = from_addr
+    msg["To"] = to_addr
+
+    ctx = ssl.create_default_context()
+    if use_ssl:
+        with smtplib.SMTP_SSL(host, port, context=ctx) as smtp:
+            if user:
+                smtp.login(user, password)
+            smtp.sendmail(from_addr, [to_addr], msg.as_string())
+    else:
+        with smtplib.SMTP(host, port, timeout=15) as smtp:
+            if use_tls:
+                smtp.starttls(context=ctx)
+            if user:
+                smtp.login(user, password)
+            smtp.sendmail(from_addr, [to_addr], msg.as_string())

+ 1 - 0
sources/templates/base.html

@@ -21,6 +21,7 @@
       <div class="flex items-center gap-4 text-sm">
         <a href="{{ url_for('index') }}" class="text-gray-300 hover:text-white transition">Dashboard</a>
         <a href="{{ url_for('destinations_list') }}" class="text-gray-300 hover:text-white transition">Destinations</a>
+        <a href="{{ url_for('settings') }}" class="text-gray-300 hover:text-white transition">Paramètres</a>
         <a href="{{ url_for('job_new') }}"
            class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded font-medium transition">
           + Nouveau job

+ 107 - 0
sources/templates/settings.html

@@ -0,0 +1,107 @@
+{% extends "base.html" %}
+{% block title %}Paramètres{% endblock %}
+
+{% block content %}
+<div class="max-w-xl">
+  <h1 class="text-xl font-bold text-gray-900 mb-6">Paramètres</h1>
+
+  <form method="post" class="space-y-6">
+
+    <div class="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
+      <h2 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">Serveur SMTP</h2>
+
+      <div class="grid grid-cols-3 gap-3">
+        <div class="col-span-2">
+          <label class="block text-sm font-medium text-gray-700 mb-1">Hôte SMTP</label>
+          <input type="text" name="smtp_host" value="{{ cfg.smtp_host }}"
+                 placeholder="smtp.exemple.fr"
+                 class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500">
+        </div>
+        <div>
+          <label class="block text-sm font-medium text-gray-700 mb-1">Port</label>
+          <input type="number" name="smtp_port" min="1" max="65535"
+                 value="{{ cfg.smtp_port or 587 }}"
+                 class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500">
+        </div>
+      </div>
+
+      <div class="grid grid-cols-2 gap-3">
+        <div>
+          <label class="block text-sm font-medium text-gray-700 mb-1">Utilisateur</label>
+          <input type="text" name="smtp_user" value="{{ cfg.smtp_user }}"
+                 placeholder="user@exemple.fr"
+                 class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500">
+        </div>
+        <div>
+          <label class="block text-sm font-medium text-gray-700 mb-1">Mot de passe</label>
+          <input type="password" name="smtp_password" value="{{ cfg.smtp_password }}"
+                 placeholder="••••••••"
+                 class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
+        </div>
+      </div>
+
+      <div class="grid grid-cols-2 gap-3">
+        <div>
+          <label class="block text-sm font-medium text-gray-700 mb-1">Expéditeur (From)</label>
+          <input type="email" name="smtp_from" value="{{ cfg.smtp_from }}"
+                 placeholder="backup@exemple.fr"
+                 class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500">
+          <p class="text-xs text-gray-400 mt-1">Si vide, utilise l'utilisateur SMTP.</p>
+        </div>
+        <div>
+          <label class="block text-sm font-medium text-gray-700 mb-1">Destinataire (To)</label>
+          <input type="email" name="smtp_to" value="{{ cfg.smtp_to }}"
+                 placeholder="admin@exemple.fr"
+                 class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500">
+        </div>
+      </div>
+
+      <div class="flex items-center gap-6">
+        <label class="flex items-center gap-2">
+          <input type="checkbox" name="smtp_tls" value="1"
+                 {% if cfg.smtp_tls == '1' %}checked{% endif %}
+                 class="rounded border-gray-300 text-blue-600">
+          <span class="text-sm font-medium text-gray-700">STARTTLS</span>
+        </label>
+        <label class="flex items-center gap-2">
+          <input type="checkbox" name="smtp_ssl" value="1"
+                 {% if cfg.smtp_ssl == '1' %}checked{% endif %}
+                 class="rounded border-gray-300 text-blue-600">
+          <span class="text-sm font-medium text-gray-700">SSL/TLS direct (port 465)</span>
+        </label>
+      </div>
+    </div>
+
+    <div class="bg-white rounded-xl border border-gray-200 p-6 space-y-3">
+      <h2 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">Déclenchement</h2>
+
+      <label class="flex items-center gap-2">
+        <input type="checkbox" name="notify_on_error" value="1"
+               {% if cfg.notify_on_error != '0' %}checked{% endif %}
+               class="rounded border-gray-300 text-blue-600">
+        <span class="text-sm font-medium text-gray-700">Notifier en cas d'erreur</span>
+      </label>
+
+      <label class="flex items-center gap-2">
+        <input type="checkbox" name="notify_on_success" value="1"
+               {% if cfg.notify_on_success == '1' %}checked{% endif %}
+               class="rounded border-gray-300 text-blue-600">
+        <span class="text-sm font-medium text-gray-700">Notifier en cas de succès</span>
+      </label>
+    </div>
+
+    <div class="flex gap-3 flex-wrap">
+      <button type="submit" name="action" value="save"
+              class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2 rounded-lg font-medium text-sm transition">
+        Enregistrer
+      </button>
+
+      <button type="submit" name="action" value="test_smtp"
+              class="bg-white hover:bg-gray-50 text-gray-700 border border-gray-300 px-5 py-2 rounded-lg font-medium text-sm transition">
+        Envoyer un email de test
+      </button>
+    </div>
+
+  </form>
+</div>
+{% endblock %}