From 11b5675eab6a06c46d7cf6df1625eacc3a54ccf11cba6c79f95c11950ef8fcf3 Mon Sep 17 00:00:00 2001 From: tlg Date: Thu, 9 Jul 2026 11:02:05 +0200 Subject: [PATCH] feat(projekt-matching): Cfg dataclass, IMAP mailbox with keyword/trash handling, SMTP send --- projekt-matching/projektmatch/config.py | 20 +++++++ projekt-matching/projektmatch/mailer.py | 78 +++++++++++++++++++++++++ projekt-matching/tests/test_mailer.py | 53 +++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 projekt-matching/projektmatch/config.py create mode 100644 projekt-matching/projektmatch/mailer.py create mode 100644 projekt-matching/tests/test_mailer.py diff --git a/projekt-matching/projektmatch/config.py b/projekt-matching/projektmatch/config.py new file mode 100644 index 0000000..e77b3bf --- /dev/null +++ b/projekt-matching/projektmatch/config.py @@ -0,0 +1,20 @@ +"""Runtime configuration passed from Langflow component inputs.""" +from dataclasses import dataclass + + +@dataclass +class Cfg: + imap_user: str = "chancen@destengs.com" + imap_password: str = "" + espo_base: str = "" + espo_api_key: str = "" + notify_to: str = "" + alert_to: str = "" + threshold: int = 85 + vllm_base: str = "" + vllm_model: str = "" + flow2_id: str = "" + langflow_api_key: str = "" + langflow_base: str = "http://127.0.0.1:7860" + data_dir: str = "/app/langflow" + crm_web_base: str = "https://crm.creature-go.com" diff --git a/projekt-matching/projektmatch/mailer.py b/projekt-matching/projektmatch/mailer.py new file mode 100644 index 0000000..1e964d4 --- /dev/null +++ b/projekt-matching/projektmatch/mailer.py @@ -0,0 +1,78 @@ +"""IMAP mailbox handling and SMTP sending for chancen@destengs.com.""" +from __future__ import annotations + +import email +import imaplib +import smtplib +import ssl +from email.message import EmailMessage +from email.policy import default as default_policy + +IMAP_HOST, IMAP_PORT = "mail.destengs.com", 993 +SMTP_HOST, SMTP_PORT = "mail.destengs.com", 587 +KEYWORD = "$ProjektChecked" + + +class MailBox: + def __init__(self, user, password, conn=None): + self.conn = conn or imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT) + if conn is None: + self.conn.login(user, password) + self.conn.select("INBOX") + self._trash = None + + def unchecked_uids(self): + typ, data = self.conn.uid("SEARCH", None, f"UNKEYWORD {KEYWORD}") + if typ != "OK" or not data or not data[0]: + return [] + return [u.decode() for u in data[0].split()] + + def fetch(self, uid): + typ, data = self.conn.uid("FETCH", uid, "(BODY.PEEK[])") + return email.message_from_bytes(data[0][1], policy=default_policy) + + def flag_checked(self, uid): + self.conn.uid("STORE", uid, "+FLAGS", f"({KEYWORD})") + + def trash_folder(self): + if self._trash: + return self._trash + typ, data = self.conn.list() + candidates = [] + for raw in data or []: + line = raw.decode() if isinstance(raw, bytes) else str(raw) + name = line.rsplit(" ", 1)[-1].strip('"') + if "\\Trash" in line.split(")")[0]: + self._trash = name + return name + candidates.append(name) + for cand in ("Trash", "INBOX.Trash"): + if cand in candidates: + self._trash = cand + return cand + self._trash = "Trash" + return self._trash + + def move_to_trash(self, uid): + folder = self.trash_folder() + typ, _ = self.conn.uid("MOVE", uid, folder) + if typ != "OK": + self.conn.uid("COPY", uid, folder) + self.conn.uid("STORE", uid, "+FLAGS", "(\\Deleted)") + self.conn.expunge() + + def close(self): + try: + self.conn.logout() + except Exception: + pass + + +def send_mail(user, password, to, subject, body): + msg = EmailMessage() + msg["From"], msg["To"], msg["Subject"] = user, to, subject + msg.set_content(body) + with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30) as server: + server.starttls(context=ssl.create_default_context()) + server.login(user, password) + server.send_message(msg) diff --git a/projekt-matching/tests/test_mailer.py b/projekt-matching/tests/test_mailer.py new file mode 100644 index 0000000..5189304 --- /dev/null +++ b/projekt-matching/tests/test_mailer.py @@ -0,0 +1,53 @@ +import email +from unittest import mock + +from projektmatch import mailer + + +def fake_conn(): + conn = mock.Mock() + conn.uid.return_value = ("OK", [b"3 7"]) + conn.list.return_value = ("OK", [ + b'(\\HasNoChildren) "." "INBOX"', + b'(\\HasNoChildren \\Trash) "." "Trash"']) + return conn + + +def test_unchecked_uids_search(): + box = mailer.MailBox("u", "p", conn=fake_conn()) + assert box.unchecked_uids() == ["3", "7"] + box.conn.uid.assert_called_with("SEARCH", None, + "UNKEYWORD $ProjektChecked") + + +def test_flag_checked(): + box = mailer.MailBox("u", "p", conn=fake_conn()) + box.flag_checked("3") + box.conn.uid.assert_called_with("STORE", "3", "+FLAGS", + "($ProjektChecked)") + + +def test_trash_folder_by_special_use(): + box = mailer.MailBox("u", "p", conn=fake_conn()) + assert box.trash_folder() == "Trash" + + +def test_move_uses_move_then_fallback(): + conn = fake_conn() + box = mailer.MailBox("u", "p", conn=conn) + conn.uid.return_value = ("OK", [b""]) + box.move_to_trash("3") + assert conn.uid.call_args_list[-1].args[:2] == ("MOVE", "3") + conn.uid.side_effect = [("NO", [b""]), ("OK", [b""]), ("OK", [b""])] + box.move_to_trash("4") + assert conn.expunge.called + + +def test_send_mail_starttls(): + with mock.patch("projektmatch.mailer.smtplib.SMTP") as smtp: + server = smtp.return_value.__enter__.return_value + mailer.send_mail("u@x", "pw", "to@x", "Subj", "Body") + server.starttls.assert_called_once() + server.login.assert_called_once_with("u@x", "pw") + msg = server.send_message.call_args.args[0] + assert msg["Subject"] == "Subj" and msg["To"] == "to@x"