diff --git a/f/Backup/backup_restore_orchestrator__flow/ersten_restore_pro_server_starten.py b/f/Backup/backup_restore_orchestrator__flow/ersten_restore_pro_server_starten.py index 988c6de..e8687a5 100644 --- a/f/Backup/backup_restore_orchestrator__flow/ersten_restore_pro_server_starten.py +++ b/f/Backup/backup_restore_orchestrator__flow/ersten_restore_pro_server_starten.py @@ -1,14 +1,32 @@ -import wmill, json, paramiko, mysql.connector +import wmill, json, paramiko, io, mysql.connector + + +def _load_pkey(key_str: str): + for cls in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey]: + try: + return cls.from_private_key(io.StringIO(key_str)) + except Exception: + continue + return None + +def _ssh_connect(ip: str, user: str, password: str = "", private_key: str = "") -> paramiko.SSHClient: + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + if private_key: + ssh.connect(ip, username=user, pkey=_load_pkey(private_key)) + else: + ssh.connect(ip, username=user, password=password) + return ssh def start_restore(server, backup, job_uuid, webhook_url, webhook_tok): """Startet restore.sh auf einem Server non-blocking.""" - ssh = paramiko.SSHClient() - ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - ssh.connect( + creds = server["ssh_creds"] + ssh = _ssh_connect( server["ip"], - username=server["ssh_creds"]["user"], - password=server["ssh_creds"]["password"] + creds["user"], + creds.get("password", ""), + creds.get("private_key", ""), ) safe_client = backup["client_name"].replace("/", "_").replace(":", "_") srv_hostname = server["hostname"] diff --git a/f/Backup/backup_restore_orchestrator__flow/flow.yaml b/f/Backup/backup_restore_orchestrator__flow/flow.yaml index 8d5cbc5..eb014de 100644 --- a/f/Backup/backup_restore_orchestrator__flow/flow.yaml +++ b/f/Backup/backup_restore_orchestrator__flow/flow.yaml @@ -45,8 +45,19 @@ value: expr: results.a lock: '!inline alle_freien_restore-server_holen.lock' language: python3 + - id: i + summary: SSH-Key aus DB testen + value: + type: rawscript + content: '!inline ssh_key_versuch.py' + input_transforms: + prev: + type: javascript + expr: results.b + lock: '!inline ssh_key_versuch.lock' + language: python3 - id: g - summary: SSH-Credentials fuer alle Restore-Server aus Bitwarden + summary: SSH-Credentials fuer alle Restore-Server aus Bitwarden (Fallback) value: type: rawscript content: '!inline ssh-credentials_fuer_alle_restore-server_aus_bitwarden.py' @@ -56,7 +67,7 @@ value: value: https://bitwarden.stines.de prev: type: javascript - expr: results.b + expr: results.i lock: '!inline ssh-credentials_fuer_alle_restore-server_aus_bitwarden.lock' language: python3 - id: c @@ -114,6 +125,21 @@ value: lock: '!inline webhook_verarbeiten_&_naechsten_restore_auf_demselben_server_starten.lock' language: python3 + failure_module: + id: failure + summary: Flow-Fehler per Nextcloud Talk melden + value: + type: rawscript + content: '!inline flow_fehler_handler.py' + input_transforms: + error: + type: javascript + expr: error + flow_input: + type: javascript + expr: flow_input + lock: '!inline flow_fehler_handler.lock' + language: python3 schema: $schema: https://json-schema.org/draft/2020-12/schema type: object diff --git a/f/Backup/backup_restore_orchestrator__flow/flow_fehler_handler.lock b/f/Backup/backup_restore_orchestrator__flow/flow_fehler_handler.lock new file mode 100644 index 0000000..48ff042 --- /dev/null +++ b/f/Backup/backup_restore_orchestrator__flow/flow_fehler_handler.lock @@ -0,0 +1,17 @@ +# py: 3.12 +anyio==4.12.1 +bcrypt==5.0.0 +certifi==2026.2.25 +cffi==2.0.0 +cryptography==46.0.5 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +idna==3.11 +invoke==2.2.1 +mysql-connector-python==9.6.0 +paramiko==4.0.0 +pycparser==3.0 +pynacl==1.6.2 +typing-extensions==4.15.0 +wmill==1.657.2 diff --git a/f/Backup/backup_restore_orchestrator__flow/flow_fehler_handler.py b/f/Backup/backup_restore_orchestrator__flow/flow_fehler_handler.py new file mode 100644 index 0000000..85afa17 --- /dev/null +++ b/f/Backup/backup_restore_orchestrator__flow/flow_fehler_handler.py @@ -0,0 +1,75 @@ +import wmill, base64, httpx, json, mysql.connector + +def _send_talk(message: str): + try: + nc_url = wmill.get_variable("f/Backup/nextcloud_talk_url").rstrip("/") + nc_room = wmill.get_variable("f/Backup/nextcloud_talk_room") + nc_user = wmill.get_variable("f/Backup/nextcloud_talk_user") + nc_password = wmill.get_variable("f/Backup/nextcloud_talk_password") + credentials = base64.b64encode(f"{nc_user}:{nc_password}".encode()).decode() + httpx.post( + f"{nc_url}/ocs/v2.php/apps/spreed/api/v1/chat/{nc_room}", + headers={ + "Authorization": f"Basic {credentials}", + "OCS-APIREQUEST": "true", + "Content-Type": "application/json", + "Accept": "application/json", + }, + json={"message": message}, + timeout=15, + verify=False, + ) + except Exception as e: + print(f"Talk-Fehler (nicht kritisch): {e}") + +def main(error: dict, flow_input: dict = {}): + msg = error.get("message", "Unbekannter Fehler") + name = error.get("name", "") + step_id = error.get("step_id", "") + + # Laufenden Job in DB als failed markieren und Server freigeben + try: + db_cfg = json.loads(wmill.get_variable("f/Backup/mysql_config")) + conn = mysql.connector.connect(**db_cfg) + cur = conn.cursor(dictionary=True) + + cur.execute(""" + SELECT job_uuid FROM Kunden.`bronze.restore.jobs` + WHERE status = 'running' + LIMIT 1 + """) + row = cur.fetchone() + if row: + job_uuid = row["job_uuid"] + cur.execute(""" + UPDATE Kunden.`bronze.restore.jobs` + SET status = 'failed', finished_at = NOW() + WHERE job_uuid = %s + """, (job_uuid,)) + cur.execute(""" + UPDATE Kunden.`bronze.restore.server` + SET current_job_uuid = NULL + WHERE current_job_uuid = %s + """, (job_uuid,)) + cur.execute(""" + DELETE FROM Kunden.`bronze.restore.session` + WHERE job_uuid = %s + """, (job_uuid,)) + conn.commit() + print(f"DB: Job {job_uuid} als failed markiert, Server freigegeben.") + + cur.close(); conn.close() + except Exception as e: + print(f"DB-Cleanup fehlgeschlagen (nicht kritisch): {e}") + + step_line = f"\nStep: `{step_id}`" if step_id else "" + type_line = f"\nTyp: {name}" if name else "" + talk_msg = ( + f"🚨 **Backup Restore Flow Fehler**{step_line}\n" + f"Fehler: {msg[:300]}" + f"{type_line}" + ) + + _send_talk(talk_msg) + print(f"Talk-Nachricht gesendet:\n{talk_msg}") + return {"notified": True} diff --git a/f/Backup/backup_restore_orchestrator__flow/script_deployen_&_pbs-datastores_auf_allen_servern_registrieren.py b/f/Backup/backup_restore_orchestrator__flow/script_deployen_&_pbs-datastores_auf_allen_servern_registrieren.py index a5b88da..59b5151 100644 --- a/f/Backup/backup_restore_orchestrator__flow/script_deployen_&_pbs-datastores_auf_allen_servern_registrieren.py +++ b/f/Backup/backup_restore_orchestrator__flow/script_deployen_&_pbs-datastores_auf_allen_servern_registrieren.py @@ -1,5 +1,23 @@ import wmill, json, paramiko, io, mysql.connector, re +def _load_pkey(key_str: str): + for cls in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey]: + try: + return cls.from_private_key(io.StringIO(key_str)) + except Exception: + continue + return None + +def _ssh_connect(ip: str, user: str, password: str = "", private_key: str = "") -> paramiko.SSHClient: + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + if private_key: + pkey = _load_pkey(private_key) + ssh.connect(ip, username=user, pkey=pkey) + else: + ssh.connect(ip, username=user, password=password) + return ssh + GITEA_REPO = "http://172.17.1.251:8080/sebastian.serfling/BackupScript.git" @@ -147,14 +165,16 @@ def deploy_to_server(ssh, server, pbs, pbs_host, pbs_user, pbs_pass, cur.execute(""" INSERT INTO Kunden.`bronze.restore.session` - (job_uuid, hostname, ip, ssh_user, ssh_password) - VALUES (%s, %s, %s, %s, %s) + (job_uuid, hostname, ip, ssh_user, ssh_password, ssh_private_key) + VALUES (%s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE ip=VALUES(ip), ssh_user=VALUES(ssh_user), - ssh_password=VALUES(ssh_password) + ssh_password=VALUES(ssh_password), + ssh_private_key=VALUES(ssh_private_key) """, ( job_uuid, hostname, ssh_creds["ip"], ssh_creds["user"], ssh_creds["password"], + ssh_creds.get("private_key", "") or "", )) conn.commit(); cur.close(); conn.close() @@ -207,16 +227,18 @@ def main(prev: dict, bw_result: dict = {}, datastores: list = []): print(f"WARNUNG: Keine IP fuer '{hostname}' – uebersprungen.") continue + ssh_user = creds.get("username", "root") + ssh_password = creds.get("password", "") + ssh_private_key = creds.get("private_key", "") + ssh_creds_dict = { - "ip": ip, - "user": creds.get("username", "root"), - "password": creds.get("password", ""), + "ip": ip, + "user": ssh_user, + "password": ssh_password, + "private_key": ssh_private_key, } - ssh = paramiko.SSHClient() - ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - ssh.connect(ip, username=ssh_creds_dict["user"], - password=ssh_creds_dict["password"]) + ssh = _ssh_connect(ip, ssh_user, ssh_password, ssh_private_key) try: pbs_storages = deploy_to_server( diff --git a/f/Backup/backup_restore_orchestrator__flow/ssh-credentials_fuer_alle_restore-server_aus_bitwarden.py b/f/Backup/backup_restore_orchestrator__flow/ssh-credentials_fuer_alle_restore-server_aus_bitwarden.py index a50350c..a718f7b 100644 --- a/f/Backup/backup_restore_orchestrator__flow/ssh-credentials_fuer_alle_restore-server_aus_bitwarden.py +++ b/f/Backup/backup_restore_orchestrator__flow/ssh-credentials_fuer_alle_restore-server_aus_bitwarden.py @@ -34,7 +34,13 @@ def main( if prev.get("mode") == "webhook": return prev - servers = prev.get("target_servers", []) + needs_bitwarden = prev.get("needs_bitwarden", []) + if not needs_bitwarden: + print("Alle Server per SSH-Key authentifiziert – Bitwarden wird nicht benötigt.") + return prev + + servers = [s for s in prev.get("target_servers", []) + if s["hostname"] in needs_bitwarden] bw_creds = json.loads(wmill.get_variable("f/Backup/bitwarden_api_login")) env = os.environ.copy() @@ -69,14 +75,31 @@ def main( raise Exception("Vault konnte nicht entsperrt werden") env["BW_SESSION"] = bw_session - server_creds = {} + server_creds = prev.get("server_creds", {}) + failed_servers = [] + for server in servers: hostname = server["hostname"] print(f"Hole Creds fuer: {hostname}") - creds = bw_lookup(hostname, env, run) - server_creds[hostname] = creds - print(f" -> OK: {creds['username']}@{hostname}") + try: + creds = bw_lookup(hostname, env, run) + server_creds[hostname] = creds + print(f" -> OK: {creds['username']}@{hostname}") + except Exception as e: + print(f" -> WARNUNG: {e} – Server wird übersprungen") + failed_servers.append(hostname) run(["bw", "logout"], check=False) + if failed_servers: + print(f"\nWARNUNG: Keine Credentials für: {failed_servers}") + # Server aus target_servers entfernen damit C/D sie nicht anfassen + remaining = [s for s in prev.get("target_servers", []) + if s["hostname"] not in failed_servers] + if not remaining: + raise Exception( + f"Keine Restore-Server verfügbar – Bitwarden-Lookup fehlgeschlagen für: {failed_servers}" + ) + return {**prev, "server_creds": server_creds, "target_servers": remaining} + return {**prev, "server_creds": server_creds} diff --git a/f/Backup/backup_restore_orchestrator__flow/ssh_key_versuch.lock b/f/Backup/backup_restore_orchestrator__flow/ssh_key_versuch.lock new file mode 100644 index 0000000..48ff042 --- /dev/null +++ b/f/Backup/backup_restore_orchestrator__flow/ssh_key_versuch.lock @@ -0,0 +1,17 @@ +# py: 3.12 +anyio==4.12.1 +bcrypt==5.0.0 +certifi==2026.2.25 +cffi==2.0.0 +cryptography==46.0.5 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +idna==3.11 +invoke==2.2.1 +mysql-connector-python==9.6.0 +paramiko==4.0.0 +pycparser==3.0 +pynacl==1.6.2 +typing-extensions==4.15.0 +wmill==1.657.2 diff --git a/f/Backup/backup_restore_orchestrator__flow/ssh_key_versuch.py b/f/Backup/backup_restore_orchestrator__flow/ssh_key_versuch.py new file mode 100644 index 0000000..b5c01b3 --- /dev/null +++ b/f/Backup/backup_restore_orchestrator__flow/ssh_key_versuch.py @@ -0,0 +1,81 @@ +import wmill, mysql.connector, json, paramiko, io + +def _load_pkey(key_str: str): + for cls in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey]: + try: + return cls.from_private_key(io.StringIO(key_str)) + except Exception: + continue + return None + +def main(prev: dict): + if prev.get("mode") == "webhook": + return prev + + servers = prev.get("target_servers", []) + db_cfg = json.loads(wmill.get_variable("f/Backup/mysql_config")) + conn = mysql.connector.connect(**db_cfg) + cur = conn.cursor(dictionary=True) + + hostnames = [s["hostname"] for s in servers] + placeholders = ",".join(["%s"] * len(hostnames)) + try: + cur.execute(f""" + SELECT hostname, ip, ssh_private_key, ssh_key_user + FROM Kunden.`bronze.restore.server` + WHERE hostname IN ({placeholders}) + AND ssh_private_key IS NOT NULL + AND ssh_private_key != '' + """, hostnames) + key_rows = {row["hostname"]: row for row in cur.fetchall()} + except Exception as e: + print(f"WARNUNG: SSH-Key-Spalten nicht in DB vorhanden ({e})") + print("Alle Server werden über Bitwarden authentifiziert.") + key_rows = {} + cur.close(); conn.close() + + server_creds = {} + needs_bitwarden = [] + + for server in servers: + hostname = server["hostname"] + ip = server.get("ip", "") + + if hostname not in key_rows: + print(f"[{hostname}] Kein SSH-Key in DB → Bitwarden") + needs_bitwarden.append(hostname) + continue + + row = key_rows[hostname] + key_str = row["ssh_private_key"] + user = row.get("ssh_key_user") or "root" + actual_ip = row.get("ip") or ip + + pkey = _load_pkey(key_str) + if pkey is None: + print(f"[{hostname}] Key-Format nicht erkannt → Bitwarden") + needs_bitwarden.append(hostname) + continue + + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + ssh.connect(actual_ip, username=user, pkey=pkey, + timeout=5, auth_timeout=5) + ssh.close() + print(f"[{hostname}] SSH-Key Auth OK ✓ ({user}@{actual_ip})") + server_creds[hostname] = { + "username": user, + "password": "", + "private_key": key_str, + "auth_method": "key", + "url": f"https://{actual_ip}", + } + except Exception as e: + print(f"[{hostname}] SSH-Key fehlgeschlagen: {e} → Bitwarden") + needs_bitwarden.append(hostname) + + print(f"Key-Auth OK: {list(server_creds.keys())}") + print(f"Braucht Bitwarden: {needs_bitwarden}") + + return {**prev, "server_creds": server_creds, "needs_bitwarden": needs_bitwarden} diff --git a/f/Backup/backup_restore_orchestrator__flow/testpause.lock b/f/Backup/backup_restore_orchestrator__flow/testpause.lock new file mode 100644 index 0000000..48ff042 --- /dev/null +++ b/f/Backup/backup_restore_orchestrator__flow/testpause.lock @@ -0,0 +1,17 @@ +# py: 3.12 +anyio==4.12.1 +bcrypt==5.0.0 +certifi==2026.2.25 +cffi==2.0.0 +cryptography==46.0.5 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +idna==3.11 +invoke==2.2.1 +mysql-connector-python==9.6.0 +paramiko==4.0.0 +pycparser==3.0 +pynacl==1.6.2 +typing-extensions==4.15.0 +wmill==1.657.2 diff --git a/f/Backup/backup_restore_orchestrator__flow/testpause.py b/f/Backup/backup_restore_orchestrator__flow/testpause.py new file mode 100644 index 0000000..704e31f --- /dev/null +++ b/f/Backup/backup_restore_orchestrator__flow/testpause.py @@ -0,0 +1,23 @@ +def main(prev: dict): + print("=" * 60) + print("TESTPAUSE — Flow stoppt hier. Ergebnisse:") + print("=" * 60) + + server_creds = prev.get("server_creds", {}) + needs_bitwarden = prev.get("needs_bitwarden", []) + servers = prev.get("target_servers", []) + + print(f"\nServer gesamt: {[s['hostname'] for s in servers]}") + print(f"needs_bitwarden: {needs_bitwarden}") + print(f"\nAuthentifizierung:") + for hostname, creds in server_creds.items(): + method = creds.get("auth_method", "password") + user = creds.get("username", "?") + print(f" {hostname}: {method} ({user})") + + for hostname in needs_bitwarden: + if hostname not in server_creds: + print(f" {hostname}: FEHLT — weder Key noch Bitwarden!") + + print("=" * 60) + return prev diff --git a/f/Backup/backup_restore_orchestrator__flow/webhook_verarbeiten_&_naechsten_restore_auf_demselben_server_starten.py b/f/Backup/backup_restore_orchestrator__flow/webhook_verarbeiten_&_naechsten_restore_auf_demselben_server_starten.py index f416e5c..2d5b341 100644 --- a/f/Backup/backup_restore_orchestrator__flow/webhook_verarbeiten_&_naechsten_restore_auf_demselben_server_starten.py +++ b/f/Backup/backup_restore_orchestrator__flow/webhook_verarbeiten_&_naechsten_restore_auf_demselben_server_starten.py @@ -1,4 +1,21 @@ -import wmill, json, mysql.connector, paramiko, re, base64 +import wmill, json, mysql.connector, paramiko, io, re, base64 + +def _load_pkey(key_str: str): + for cls in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey]: + try: + return cls.from_private_key(io.StringIO(key_str)) + except Exception: + continue + return None + +def _ssh_connect(ip: str, user: str, password: str = "", private_key: str = "") -> paramiko.SSHClient: + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + if private_key: + ssh.connect(ip, username=user, pkey=_load_pkey(private_key)) + else: + ssh.connect(ip, username=user, password=password) + return ssh from datetime import datetime @@ -349,7 +366,7 @@ def main(from_init: dict): conn3 = mysql.connector.connect(**db_cfg) cur3 = conn3.cursor(dictionary=True) cur3.execute(""" - SELECT ip, ssh_user, ssh_password + SELECT ip, ssh_user, ssh_password, ssh_private_key FROM Kunden.`bronze.restore.session` WHERE job_uuid = %s AND hostname = %s LIMIT 1 @@ -366,12 +383,11 @@ def main(from_init: dict): webhook_url = wmill.get_variable("f/Backup/windmill_webhook_url") webhook_tok = wmill.get_variable("f/Backup/windmill_webhook_token") - ssh = paramiko.SSHClient() - ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - ssh.connect( + ssh = _ssh_connect( session["ip"], - username=session["ssh_user"], - password=session["ssh_password"] + session["ssh_user"], + session.get("ssh_password", "") or "", + session.get("ssh_private_key", "") or "", ) safe_client = nxt["client_name"].replace("/", "_").replace(":", "_") diff --git a/f/mail_to_talk/folder.meta.yaml b/f/mail_to_talk/folder.meta.yaml new file mode 100644 index 0000000..8a7e965 --- /dev/null +++ b/f/mail_to_talk/folder.meta.yaml @@ -0,0 +1,6 @@ +summary: null +display_name: mail_to_talk +extra_perms: + serfling@itdata-gera.de: true +owners: + - serfling@itdata-gera.de diff --git a/f/mail_to_talk/imap_config.resource.yaml b/f/mail_to_talk/imap_config.resource.yaml new file mode 100644 index 0000000..9d0b474 --- /dev/null +++ b/f/mail_to_talk/imap_config.resource.yaml @@ -0,0 +1,8 @@ +description: IMAP Zugangsdaten fuer den Mail-zu-Talk Flow +resource_type: imap +value: + host: mail.stines.de + port: 993 + user: sebastianserfling@stines.de + password: c6tzJBxDtNEU84ZAWY39eG8RUM5XR4UyfrDesfgCekEbFhkHDkbn + mailbox: INBOX diff --git a/f/mail_to_talk/imap_config_2.resource.yaml b/f/mail_to_talk/imap_config_2.resource.yaml new file mode 100644 index 0000000..4502df4 --- /dev/null +++ b/f/mail_to_talk/imap_config_2.resource.yaml @@ -0,0 +1,8 @@ +description: IMAP Zugangsdaten fuer das zweite Postfach (itdata-gera.de) +resource_type: imap +value: + host: mail.itdata-gera.de + port: 993 + user: serfling@itdata-gera.de + password: '' + mailbox: INBOX diff --git a/f/mail_to_talk/mail_to_talk__flow/fetch_emails.py b/f/mail_to_talk/mail_to_talk__flow/fetch_emails.py new file mode 100644 index 0000000..a38e5c7 --- /dev/null +++ b/f/mail_to_talk/mail_to_talk__flow/fetch_emails.py @@ -0,0 +1,85 @@ +import imaplib +import email +from email.header import decode_header +from typing import TypedDict, Optional, List +from datetime import datetime +import email.utils + + +class imap(TypedDict): + host: str + port: int + user: str + password: str + mailbox: Optional[str] + + +def decode_str(value) -> str: + if value is None: + return "" + parts = decode_header(value) + result = [] + for part, charset in parts: + if isinstance(part, bytes): + result.append(part.decode(charset or "utf-8", errors="replace")) + else: + result.append(part) + return "".join(result) + + +def main(imap_config: imap) -> List[dict]: + host = imap_config["host"] + port = imap_config["port"] + user = imap_config["user"] + password = imap_config["password"] + mailbox = imap_config.get("mailbox") or "INBOX" + + if port == 993: + client = imaplib.IMAP4_SSL(host, port) + else: + client = imaplib.IMAP4(host, port) + + client.login(user, password) + client.select(mailbox, readonly=False) + + # Nur ungelesene E-Mails suchen + status, data = client.search(None, "UNSEEN") + if status != "OK" or not data[0]: + client.logout() + return [] + + uids = data[0].split() + emails = [] + + for uid in uids: + status, msg_data = client.fetch(uid, "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])") + if status != "OK": + continue + + raw = msg_data[0][1] + msg = email.message_from_bytes(raw) + + subject = decode_str(msg.get("Subject", "(Kein Betreff)")) + from_raw = msg.get("From", "Unbekannt") + from_addr = decode_str(from_raw) + + date_raw = msg.get("Date", "") + try: + parsed_date = email.utils.parsedate_to_datetime(date_raw) + date_str = parsed_date.strftime("%d.%m.%Y %H:%M:%S") + except Exception: + date_str = date_raw or "Unbekanntes Datum" + + # Als gelesen markieren + client.store(uid, "+FLAGS", "\\Seen") + + emails.append({ + "subject": subject, + "from": from_addr, + "date": date_str, + "uid": int(uid), + "account": user, + }) + + client.logout() + return emails diff --git a/f/mail_to_talk/mail_to_talk__flow/flow.yaml b/f/mail_to_talk/mail_to_talk__flow/flow.yaml new file mode 100644 index 0000000..46b231c --- /dev/null +++ b/f/mail_to_talk/mail_to_talk__flow/flow.yaml @@ -0,0 +1,77 @@ +summary: E-Mails zu Nextcloud Talk +description: > + Liest ungelesene E-Mails von zwei IMAP-Konten und sendet Benachrichtigungen + an einen Nextcloud Talk Raum. E-Mails werden als gelesen markiert. +schema: + $schema: "http://json-schema.org/draft-07/schema" + type: object + properties: + imap_config_1: + type: object + format: resource-imap + description: "IMAP Konto 1 (Ressource: f/mail_to_talk/imap_config)" + imap_config_2: + type: object + format: resource-imap + description: "IMAP Konto 2 (Ressource: f/mail_to_talk/imap_config_2)" + nextcloud_config: + type: object + format: resource-nextcloud + description: "Nextcloud Zugangsdaten (Ressource: f/mail_to_talk/nextcloud_talk_config)" + required: + - imap_config_1 + - imap_config_2 + - nextcloud_config +value: + modules: + - id: fetch_emails_1 + summary: Ungelesene E-Mails Konto 1 abrufen + value: + type: rawscript + language: python3 + content: "!inline fetch_emails.py" + input_transforms: + imap_config: + type: javascript + expr: flow_input.imap_config_1 + stop_after_if: + expr: "false" + skip_if_stopped: false + + - id: fetch_emails_2 + summary: Ungelesene E-Mails Konto 2 abrufen + value: + type: rawscript + language: python3 + content: "!inline fetch_emails.py" + input_transforms: + imap_config: + type: javascript + expr: flow_input.imap_config_2 + stop_after_if: + expr: "false" + skip_if_stopped: false + + - id: send_to_talk + summary: Jede E-Mail an Nextcloud Talk senden + value: + type: forloopflow + iterator: + type: javascript + expr: "[...results.fetch_emails_1, ...results.fetch_emails_2]" + skip_failures: false + parallel: false + modules: + - id: send_message + summary: Nachricht an Nextcloud Talk senden + value: + type: rawscript + language: python3 + content: "!inline send_message.py" + input_transforms: + email: + type: javascript + expr: flow_input.iter.value + nextcloud_config: + type: javascript + expr: flow_input.nextcloud_config diff --git a/f/mail_to_talk/mail_to_talk__flow/send_message.py b/f/mail_to_talk/mail_to_talk__flow/send_message.py new file mode 100644 index 0000000..701ebad --- /dev/null +++ b/f/mail_to_talk/mail_to_talk__flow/send_message.py @@ -0,0 +1,49 @@ +import urllib.request +import urllib.parse +import json +import base64 +from typing import TypedDict + + +class nextcloud(TypedDict): + baseUrl: str + userId: str + token: str + + +TALK_TOKEN = "6bdts22w" + + +def main(email: dict, nextcloud_config: nextcloud) -> dict: + base_url = nextcloud_config["baseUrl"].rstrip("/") + user_id = nextcloud_config["userId"] + token = nextcloud_config["token"] + + message = ( + f"Neue E-Mail an: {email.get('account', '')}\n" + f"Von: {email['from']}\n" + f"Betreff: {email['subject']}\n" + f"Datum: {email['date']}" + ) + + url = f"{base_url}/ocs/v2.php/apps/spreed/api/v1/chat/{TALK_TOKEN}" + credentials = base64.b64encode(f"{user_id}:{token}".encode()).decode() + + body = json.dumps({"message": message, "replyTo": 0}).encode("utf-8") + + req = urllib.request.Request( + url, + data=body, + headers={ + "OCS-APIRequest": "true", + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": f"Basic {credentials}", + }, + method="POST", + ) + + with urllib.request.urlopen(req) as resp: + status = resp.status + + return {"success": True, "status": status} diff --git a/f/mail_to_talk/nextcloud_talk_config.resource.yaml b/f/mail_to_talk/nextcloud_talk_config.resource.yaml new file mode 100644 index 0000000..f12cf47 --- /dev/null +++ b/f/mail_to_talk/nextcloud_talk_config.resource.yaml @@ -0,0 +1,6 @@ +description: Nextcloud Zugangsdaten fuer den Mail-zu-Talk Flow +resource_type: nextcloud +value: + baseUrl: https://cloudstorage.stines.de + userId: reporting + token: "5xw#HLH5kbMDbxNUUVA6iQcstytm4Ss4g9iGy7ZoLCTDTku6GPcXNHgRfSFgci9R" diff --git a/imap.resource-type.yaml b/imap.resource-type.yaml new file mode 100644 index 0000000..3d9d6ec --- /dev/null +++ b/imap.resource-type.yaml @@ -0,0 +1,38 @@ +description: IMAP mailbox credentials +format_extension: null +is_fileset: false +schema: + $schema: https://json-schema.org/draft/2020-12/schema + type: object + order: + - host + - port + - user + - password + - mailbox + properties: + host: + type: string + description: IMAP server hostname + default: '' + port: + type: integer + description: 'IMAP port (993 = SSL, 143 = plain)' + default: 993 + user: + type: string + description: IMAP username / e-mail address + default: '' + password: + type: string + description: IMAP password or app password + default: '' + mailbox: + type: string + description: Mailbox to monitor (default INBOX) + default: INBOX + required: + - host + - port + - user + - password